Files
cmroubao/backend-api/internal/usecase/freight_service_test.go
T

691 lines
17 KiB
Go

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,
)
}
}
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 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{}
service, err := NewFreightService(
repository,
&recordingDateSource{},
fakeClock{},
&sequenceIDs{},
time.Minute,
)
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)
}
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,
)
}
}
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
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: "BLACK-L",
Quantity: &quantity,
ProductThumbRef: &thumb,
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
}