package usecase import ( "context" "errors" "sync" "testing" "time" "cmroubao/backend-api/internal/domain" ) func TestFreightNormalizationHashExcludesInternalIDs(t *testing.T) { source := validFreightSource() firstService := &FreightService{ids: &sequenceIDs{next: 10}} first, err := firstService.normalize(source) if err != nil { t.Fatalf("first normalize error = %v", err) } secondService := &FreightService{ids: &sequenceIDs{next: 100}} second, err := secondService.normalize(source) if err != nil { t.Fatalf("second normalize error = %v", err) } if first.Orders[0].ID == second.Orders[0].ID { t.Fatal("test IDs did not differ") } if first.Orders[0].CanonicalSHA256 != second.Orders[0].CanonicalSHA256 { t.Fatalf( "canonical hash depends on internal ID: %s != %s", first.Orders[0].CanonicalSHA256, second.Orders[0].CanonicalSHA256, ) } changed := validFreightSource() changedPrice := *changed.Orders[0].Items[0].OriginalUnitPriceMinor + 1 changed.Orders[0].Items[0].OriginalUnitPriceMinor = &changedPrice changedBatch, err := firstService.normalize(changed) if err != nil { t.Fatalf("changed price normalize error = %v", err) } if first.Orders[0].CanonicalSHA256 == changedBatch.Orders[0].CanonicalSHA256 || first.Orders[0].Items[0].CanonicalSHA256 == changedBatch.Orders[0].Items[0].CanonicalSHA256 { t.Fatal("canonical hash ignored original unit price") } } func TestFreightNormalizationRejectsConflictingIdentityAndInvalidTime( t *testing.T, ) { duplicate := validFreightSource() duplicate.Orders[0].Items = append( duplicate.Orders[0].Items, duplicate.Orders[0].Items[0], ) service := &FreightService{ids: &sequenceIDs{}} if _, err := service.normalize(duplicate); err == nil { t.Fatal("duplicate item normalize error = nil") } invalidTime := validFreightSource() value := "not-a-time" invalidTime.Orders[0].SourceCreatedAt = &value if _, err := service.normalize(invalidTime); err == nil { t.Fatal("invalid source time normalize error = nil") } } func TestParseERPTimeAcceptsConfirmedMinuteAndSecondPrecision(t *testing.T) { shanghaiExpected := time.Date(2026, 7, 29, 4, 34, 0, 0, time.UTC) shanghaiSecondExpected := time.Date( 2026, 7, 29, 4, 34, 56, 0, time.UTC, ) testCases := []struct { name string value string expected time.Time }{ {"space minute", "2026-07-29 12:34", shanghaiExpected}, {"T minute", "2026-07-29T12:34", shanghaiExpected}, {"space second", "2026-07-29 12:34:56", shanghaiSecondExpected}, {"T second", "2026-07-29T12:34:56", shanghaiSecondExpected}, { "RFC3339", "2026-07-29T12:34:56+08:00", shanghaiSecondExpected, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { actual, err := parseERPTime(&testCase.value) if err != nil || actual == nil || !actual.Equal(testCase.expected) { t.Fatalf( "parseERPTime(%q) = %v, %v; want %v", testCase.value, actual, err, testCase.expected, ) } }) } } func TestParseERPTimeRejectsUnknownMinuteFormats(t *testing.T) { for _, value := range []string{ "2026-02-30 12:34", "2026-07-29 12", "2026-07-29 12:34 extra", "29/07/2026 12:34", } { if actual, err := parseERPTime(&value); err == nil || actual != nil { t.Fatalf("parseERPTime(%q) = %v, %v", value, actual, err) } } } func TestFreightNormalizationAcceptsDetailMinutePrecisionTime(t *testing.T) { source := validFreightSource() value := "2026-07-29 12:34" source.Orders[0].SourceCreatedAt = &value service := &FreightService{ids: &sequenceIDs{}} result, err := service.normalize(source) expected := time.Date(2026, 7, 29, 4, 34, 0, 0, time.UTC) if err != nil || len(result.Orders) != 1 || result.Orders[0].SourceCreatedAt == nil || !result.Orders[0].SourceCreatedAt.Equal(expected) { t.Fatalf("normalize minute time = %+v, %v", result.Orders, err) } } func TestFreightSourceErrorCodesAreSourceNeutral(t *testing.T) { cases := []struct { err error want string }{ {domain.ErrFreightSourceNotConfigured, "ERP_NOT_CONFIGURED"}, {domain.ErrFreightSourceSessionNeeded, "ERP_SESSION_REQUIRED"}, {domain.ErrFreightSourceNotFound, "ERP_FREIGHT_NOT_FOUND"}, {domain.ErrFreightSourceProtocol, "ERP_RESPONSE_INVALID"}, {domain.ErrFreightSourceOCRInvalid, "OCR_SERVICE_INVALID"}, {domain.ErrFreightSourceLoginRejected, "ERP_LOGIN_REJECTED"}, {errors.New("temporary source failure"), "ERP_UNAVAILABLE"}, } for _, testCase := range cases { if got := freightSourceErrorCode(testCase.err); got != testCase.want { t.Fatalf("freightSourceErrorCode(%v) = %q, want %q", testCase.err, got, testCase.want) } } } func TestCreateFreightOrderSyncStopsBeforePersistingWhenOCRIsInvalid(t *testing.T) { repository := &dateCaptureRepository{} service, err := NewFreightService( repository, &recordingDateSource{ensureErr: domain.ErrFreightSourceOCRInvalid}, fakeClock{}, &sequenceIDs{}, time.Minute, ) if err != nil { t.Fatalf("NewFreightService() error = %v", err) } _, err = service.CreateOrderSync(context.Background(), CreateFreightSyncCommand{ CreatorSubject: "local-admin", ActorUserID: "00000000-0000-4000-8000-000000000099", IdempotencyKey: "ocr-invalid", OrderNumber: "ORDER-123", }) if !errors.Is(err, domain.ErrFreightSourceOCRInvalid) || repository.createCalls != 0 { t.Fatalf("CreateOrderSync() error/calls = %v / %d", err, repository.createCalls) } } func TestCreateFreightOrderSyncReturnsCommittedResult(t *testing.T) { repository := &syncTrackingRepository{} imageCache := &recordingFreightImageCache{ err: errors.New("best effort image failure"), } service, err := NewFreightService( repository, &recordingDateSource{}, fakeClock{}, &sequenceIDs{}, time.Minute, WithFreightImageCache(imageCache), ) if err != nil { t.Fatalf("NewFreightService() error = %v", err) } if service.orderTimeout != 55*time.Second { t.Fatalf("order timeout = %s", service.orderTimeout) } result, err := service.CreateOrderSync( context.Background(), validFreightOrderSyncCommand("sync-success"), ) if err != nil { t.Fatalf("CreateOrderSync() error = %v", err) } if result.Replayed || result.Run.Status != domain.FreightSyncSucceeded || result.Run.OrderCount != 1 || result.Run.ItemCount != 1 || result.Run.StartedAt == nil || result.Run.FinishedAt == nil { t.Fatalf("CreateOrderSync() result = %+v", result) } if imageCache.calls != 1 || imageCache.creatorSubject != "local-admin" || imageCache.runID != result.Run.ID { t.Fatalf("image cache calls = %+v", imageCache) } repository.mu.Lock() defer repository.mu.Unlock() if repository.startCalls != 1 || repository.completeCalls != 1 || repository.failCalls != 0 { t.Fatalf( "start/complete/fail calls = %d/%d/%d", repository.startCalls, repository.completeCalls, repository.failCalls, ) } } type recordingFreightImageCache struct { calls int creatorSubject string runID string err error } func (cache *recordingFreightImageCache) CacheRun( _ context.Context, creatorSubject, runID string, ) error { cache.calls++ cache.creatorSubject = creatorSubject cache.runID = runID return cache.err } func TestCreateFreightOrderSyncTimeoutUsesLiveCleanupContext(t *testing.T) { repository := &syncTrackingRepository{} source := &blockingOrderSource{} service, err := NewFreightService( repository, source, fakeClock{}, &sequenceIDs{}, time.Minute, ) if err != nil { t.Fatalf("NewFreightService() error = %v", err) } service.orderTimeout = 10 * time.Millisecond _, err = service.CreateOrderSync( context.Background(), validFreightOrderSyncCommand("sync-timeout"), ) assertUsecaseError(t, err, ErrorKindUnavailable, "FREIGHT_SYNC_TIMEOUT") repository.mu.Lock() defer repository.mu.Unlock() if repository.failCalls != 1 || repository.failCode != "FREIGHT_SYNC_TIMEOUT" || repository.failContextErr != nil { t.Fatalf( "failure calls/code/context = %d/%q/%v", repository.failCalls, repository.failCode, repository.failContextErr, ) } } func TestCreateFreightOrderSyncCancellationUsesLiveCleanupContext(t *testing.T) { repository := &syncTrackingRepository{} source := &blockingOrderSource{started: make(chan struct{})} service, err := NewFreightService( repository, source, fakeClock{}, &sequenceIDs{}, time.Minute, ) if err != nil { t.Fatalf("NewFreightService() error = %v", err) } ctx, cancel := context.WithCancel(context.Background()) result := make(chan error, 1) go func() { _, runErr := service.CreateOrderSync( ctx, validFreightOrderSyncCommand("sync-canceled"), ) result <- runErr }() select { case <-source.started: case <-time.After(time.Second): t.Fatal("order sync did not reach source") } cancel() err = <-result assertUsecaseError(t, err, ErrorKindUnavailable, "FREIGHT_SYNC_CANCELED") repository.mu.Lock() defer repository.mu.Unlock() if repository.failCalls != 1 || repository.failCode != "FREIGHT_SYNC_CANCELED" || repository.failContextErr != nil { t.Fatalf( "failure calls/code/context = %d/%q/%v", repository.failCalls, repository.failCode, repository.failContextErr, ) } } func TestCreateFreightOrderSyncRejectsConcurrentRequest(t *testing.T) { repository := &syncTrackingRepository{} source := &blockingOrderSource{ started: make(chan struct{}), release: make(chan struct{}), } service, err := NewFreightService( repository, source, fakeClock{}, &sequenceIDs{}, time.Minute, ) if err != nil { t.Fatalf("NewFreightService() error = %v", err) } firstResult := make(chan error, 1) go func() { _, runErr := service.CreateOrderSync( context.Background(), validFreightOrderSyncCommand("sync-first"), ) firstResult <- runErr }() select { case <-source.started: case <-time.After(time.Second): t.Fatal("first order sync did not reach source") } startedAt := time.Now() _, err = service.CreateOrderSync( context.Background(), validFreightOrderSyncCommand("sync-second"), ) assertUsecaseError(t, err, ErrorKindConflict, "FREIGHT_SYNC_BUSY") if time.Since(startedAt) > 100*time.Millisecond { t.Fatalf("busy response took %s", time.Since(startedAt)) } close(source.release) if err := <-firstResult; err != nil { t.Fatalf("first CreateOrderSync() error = %v", err) } } func validFreightOrderSyncCommand(idempotencyKey string) CreateFreightSyncCommand { return CreateFreightSyncCommand{ CreatorSubject: "local-admin", ActorUserID: "00000000-0000-4000-8000-000000000099", IdempotencyKey: idempotencyKey, OrderNumber: "ORDER-123", } } func TestFreightDateQuerySplitsIntoSevenDayWindows(t *testing.T) { source := &recordingDateSource{} service := &FreightService{source: source} result, err := service.queryCreatedRange( context.Background(), domain.FreightSyncRun{ Mode: domain.FreightSyncCreatedRange, CreatedFrom: "2026-07-01", CreatedTo: "2026-07-15", }, ) if err != nil { t.Fatalf("queryCreatedRange() error = %v", err) } want := [][2]string{ {"2026-07-01", "2026-07-07"}, {"2026-07-08", "2026-07-14"}, {"2026-07-15", "2026-07-15"}, } if len(source.calls) != len(want) { t.Fatalf("calls = %#v", source.calls) } for index := range want { if source.calls[index] != want[index] { t.Fatalf("call %d = %#v, want %#v", index, source.calls[index], want[index]) } } if result.Query.CreatedFrom == nil || *result.Query.CreatedFrom != "2026-07-01" || result.Query.CreatedTo == nil || *result.Query.CreatedTo != "2026-07-15" { t.Fatalf("merged query = %+v", result.Query) } } func TestFreightDateQueryStopsOnMiddleWindowFailure(t *testing.T) { source := &recordingDateSource{failOnCall: 2} service := &FreightService{source: source} _, err := service.queryCreatedRange( context.Background(), domain.FreightSyncRun{ Mode: domain.FreightSyncCreatedRange, CreatedFrom: "2026-07-01", CreatedTo: "2026-07-15", }, ) if !errors.Is(err, errDateSourceFailure) || len(source.calls) != 2 { t.Fatalf("queryCreatedRange() error/calls = %v / %#v", err, source.calls) } } func TestCreateFreightSyncToNowUsesWatermarkOverlapDay(t *testing.T) { watermarkTime := time.Date(2026, 7, 25, 16, 5, 0, 0, time.UTC) repository := &dateCaptureRepository{ watermark: &domain.FreightSyncWatermark{ CreatorSubject: "local-admin", SourceSystem: domain.FreightSourceShunyunbao, LastSuccessfulTo: watermarkTime, }, } service, err := NewFreightService( repository, &recordingDateSource{}, fakeClock{}, &sequenceIDs{}, time.Minute, ) if err != nil { t.Fatalf("NewFreightService() error = %v", err) } result, err := service.CreateDateSync( context.Background(), CreateFreightDateSyncCommand{ CreatorSubject: "local-admin", ActorUserID: "00000000-0000-4000-8000-000000000099", IdempotencyKey: "sync-to-now", SyncToNow: true, }, ) if err != nil { t.Fatalf("CreateDateSync() error = %v", err) } if result.Run.CreatedFrom != "2026-07-25" || result.Run.CreatedTo != "2026-07-26" || result.Run.WatermarkThrough == nil || !result.Run.WatermarkThrough.Equal(fakeClock{}.Now()) { t.Fatalf("sync-to-now run = %+v", result.Run) } } func TestCreateFreightManualRangeRejectsMoreThanSevenDays(t *testing.T) { service, _ := NewFreightService( &dateCaptureRepository{}, &recordingDateSource{}, fakeClock{}, &sequenceIDs{}, time.Minute, ) _, err := service.CreateDateSync( context.Background(), CreateFreightDateSyncCommand{ CreatorSubject: "local-admin", ActorUserID: "00000000-0000-4000-8000-000000000099", IdempotencyKey: "too-wide", CreatedFrom: "2026-07-19", CreatedTo: "2026-07-26", }, ) assertUsecaseError(t, err, ErrorKindInvalid, "FREIGHT_SYNC_INVALID") } func validFreightSource() domain.FreightSourceBatch { shop := "测试店铺" sourceCreatedAt := "2026-07-28 08:00:00" orderStatus := "0" purchaseStatus := "1" thumb := "190" itemPurchaseStatus := "0" quantity := 2 originalUnitPriceMinor := int64(12950) canceled := false return domain.FreightSourceBatch{ SchemaVersion: 1, Query: domain.FreightSourceQuery{ Mode: domain.FreightSyncOrderNumber, }, Orders: []domain.FreightSourceOrder{{ ExternalStockID: "12", SourceCode: "SOURCE-12", ShopName: &shop, SourceCreatedAt: &sourceCreatedAt, OrderStatus: &orderStatus, PurchaseStatus: &purchaseStatus, IsCanceled: &canceled, Items: []domain.FreightSourceItem{{ ExternalItemID: "88", Title: "商品", ProductSpec: "黑色,L", SKU: "黑色,L", Quantity: &quantity, ProductThumbRef: &thumb, OriginalUnitPriceMinor: &originalUnitPriceMinor, OriginalCurrency: domain.FreightCurrencyTWD, PurchaseStatus: &itemPurchaseStatus, }}, }}, } } var errDateSourceFailure = errors.New("date source failed") type recordingDateSource struct { calls [][2]string failOnCall int ensureCalls int ensureErr error } type blockingOrderSource struct { started chan struct{} release chan struct{} once sync.Once } func (source *blockingOrderSource) QueryOrder( ctx context.Context, _ string, ) (domain.FreightSourceBatch, error) { if source.started != nil { source.once.Do(func() { close(source.started) }) } if source.release != nil { select { case <-source.release: case <-ctx.Done(): return domain.FreightSourceBatch{}, ctx.Err() } } else { <-ctx.Done() return domain.FreightSourceBatch{}, ctx.Err() } return validFreightSource(), nil } func (*blockingOrderSource) QueryCreatedRange( context.Context, string, string, ) (domain.FreightSourceBatch, error) { return domain.FreightSourceBatch{}, errors.New("unexpected date query") } type syncTrackingRepository struct { mu sync.Mutex startCalls int completeCalls int failCalls int failCode string failContextErr error } func (*syncTrackingRepository) CreateFreightSync( _ context.Context, run domain.FreightSyncRun, _, _ string, ) (domain.FreightSyncRun, bool, error) { return run, true, nil } func (repository *syncTrackingRepository) StartFreightSync( context.Context, string, time.Time, ) error { repository.mu.Lock() defer repository.mu.Unlock() repository.startCalls++ return nil } func (repository *syncTrackingRepository) CompleteFreightSync( context.Context, domain.FreightSyncRun, domain.FreightImportBatch, time.Time, ) error { repository.mu.Lock() defer repository.mu.Unlock() repository.completeCalls++ return nil } func (*syncTrackingRepository) CompleteFreightDateSync( context.Context, domain.FreightSyncRun, domain.FreightImportBatch, time.Time, time.Time, ) error { return errors.New("unexpected date completion") } func (repository *syncTrackingRepository) FailFreightSync( ctx context.Context, _ string, code string, _ time.Time, ) error { repository.mu.Lock() defer repository.mu.Unlock() repository.failCalls++ repository.failCode = code repository.failContextErr = ctx.Err() return nil } func (*syncTrackingRepository) RecoverFreightSyncs( context.Context, time.Time, ) (int64, error) { return 0, nil } func (*syncTrackingRepository) GetFreightSync( context.Context, string, string, ) (domain.FreightSyncRun, error) { return domain.FreightSyncRun{}, nil } func (*syncTrackingRepository) GetFreightSyncWatermark( context.Context, string, ) (*domain.FreightSyncWatermark, error) { return nil, nil } func (*syncTrackingRepository) ListFreightOrders( context.Context, string, int, ) ([]domain.FreightOrder, error) { return nil, nil } func (*syncTrackingRepository) GetFreightOrder( context.Context, string, string, ) (domain.FreightOrderDetail, error) { return domain.FreightOrderDetail{}, nil } func (source *recordingDateSource) EnsureAuthenticated(context.Context) error { source.ensureCalls++ return source.ensureErr } func (source *recordingDateSource) QueryOrder( context.Context, string, ) (domain.FreightSourceBatch, error) { return validFreightSource(), nil } func (source *recordingDateSource) QueryCreatedRange( _ context.Context, createdFrom, createdTo string, ) (domain.FreightSourceBatch, error) { source.calls = append(source.calls, [2]string{createdFrom, createdTo}) if source.failOnCall == len(source.calls) { return domain.FreightSourceBatch{}, errDateSourceFailure } return domain.FreightSourceBatch{ SchemaVersion: 1, Query: domain.FreightSourceQuery{ Mode: domain.FreightSyncCreatedRange, CreatedFrom: &createdFrom, CreatedTo: &createdTo, }, Orders: []domain.FreightSourceOrder{}, }, nil } type dateCaptureRepository struct { watermark *domain.FreightSyncWatermark createCalls int } func (repository *dateCaptureRepository) CreateFreightSync( _ context.Context, run domain.FreightSyncRun, _, _ string, ) (domain.FreightSyncRun, bool, error) { repository.createCalls++ return run, false, nil } func (*dateCaptureRepository) StartFreightSync( context.Context, string, time.Time, ) error { return nil } func (*dateCaptureRepository) CompleteFreightSync( context.Context, domain.FreightSyncRun, domain.FreightImportBatch, time.Time, ) error { return nil } func (*dateCaptureRepository) CompleteFreightDateSync( context.Context, domain.FreightSyncRun, domain.FreightImportBatch, time.Time, time.Time, ) error { return nil } func (*dateCaptureRepository) FailFreightSync( context.Context, string, string, time.Time, ) error { return nil } func (*dateCaptureRepository) RecoverFreightSyncs( context.Context, time.Time, ) (int64, error) { return 0, nil } func (*dateCaptureRepository) GetFreightSync( context.Context, string, string, ) (domain.FreightSyncRun, error) { return domain.FreightSyncRun{}, nil } func (repository *dateCaptureRepository) GetFreightSyncWatermark( context.Context, string, ) (*domain.FreightSyncWatermark, error) { return repository.watermark, nil } func (*dateCaptureRepository) ListFreightOrders( context.Context, string, int, ) ([]domain.FreightOrder, error) { return nil, nil } func (*dateCaptureRepository) GetFreightOrder( context.Context, string, string, ) (domain.FreightOrderDetail, error) { return domain.FreightOrderDetail{}, nil }