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

382 lines
9.6 KiB
Go

package usecase
import (
"context"
"errors"
"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 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
}
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
}