package sqlite_test import ( "context" "errors" "testing" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/platform/migration" repository "cmroubao/backend-api/internal/repository/sqlite" "cmroubao/backend-api/internal/usecase" ) func TestProcurementRequestsArePerItemAndTaskSnapshotIsImmutable( t *testing.T, ) { db := openDatabase(t) store, _ := repository.New(db) ctx := context.Background() now := time.Date(2026, 7, 28, 5, 6, 7, 0, time.UTC) userID := uuid(1100) seedFreightUser(t, db, userID, now) run := freightRun(1101, userID, now) createAndStartFreightRun(t, store, run, "procurement-source-1", "1") if err := store.CompleteFreightSync( ctx, run, freightBatch(1110, "a", "b"), now.Add(time.Second), ); err != nil { t.Fatalf("CompleteFreightSync() error = %v", err) } orders, _ := store.ListFreightOrders(ctx, "local-admin", 10) detail, _ := store.GetFreightOrder(ctx, "local-admin", orders[0].ID) service, err := usecase.NewProcurementService( store, procurementClock{now: now.Add(2 * time.Second)}, &procurementIDs{next: 1200}, ) if err != nil { t.Fatalf("NewProcurementService() error = %v", err) } requests := make([]domain.ProcurementRequest, 0, len(detail.Items)) for _, item := range detail.Items { result, err := service.CreateRequest( ctx, usecase.CreateProcurementRequestCommand{ CreatorSubject: "local-admin", ActorUserID: userID, FreightOrderItemID: item.ID, ConfirmProcurementNeeded: true, }, ) if err != nil || result.Request.Status != domain.ProcurementNeedsImage { t.Fatalf("CreateRequest() = %+v, %v", result, err) } requests = append(requests, result.Request) } if len(requests) != 2 || requests[0].ID == requests[1].ID { t.Fatalf("requests = %+v", requests) } replay, err := service.CreateRequest( ctx, usecase.CreateProcurementRequestCommand{ CreatorSubject: "local-admin", ActorUserID: userID, FreightOrderItemID: detail.Items[0].ID, ConfirmProcurementNeeded: true, }, ) if err != nil || !replay.Replayed || replay.Request.ID != requests[0].ID { t.Fatalf("request replay = %+v, %v", replay, err) } asset := domain.Asset{ ID: uuid(1300), CreatorSubject: "local-admin", Purpose: domain.AssetPurposeTaskReference, MediaType: domain.NormalizedImageMediaType, SizeBytes: 100, SHA256: repeatHex("f"), StorageKey: "procurement/reference.jpg", CreatedAt: now, } if _, _, err := store.CreateAssetIdempotent( ctx, asset, "procurement-asset", repeatHex("e"), ); err != nil { t.Fatalf("CreateAssetIdempotent() error = %v", err) } bound, err := service.BindReference( ctx, usecase.BindProcurementReferenceCommand{ CreatorSubject: "local-admin", ActorUserID: userID, RequestID: requests[0].ID, ImageAssetID: asset.ID, }, ) if err != nil || bound.Status != domain.ProcurementReady { t.Fatalf("BindReference() = %+v, %v", bound, err) } created, err := service.CreateTask( ctx, usecase.CreateProcurementTaskCommand{ CreatorSubject: "local-admin", ActorUserID: userID, RequestID: requests[0].ID, IdempotencyKey: "procurement-task-1", }, ) if err != nil || created.Task.Status != domain.TaskStatusPending || created.Task.Title != detail.Items[0].Title || created.Task.SKU != detail.Items[0].SKU || created.Task.ImageAssetID != asset.ID { t.Fatalf("CreateTask() = %+v, %v", created, err) } replayedTask, err := service.CreateTask( ctx, usecase.CreateProcurementTaskCommand{ CreatorSubject: "local-admin", ActorUserID: userID, RequestID: requests[0].ID, IdempotencyKey: "procurement-task-2", }, ) if err != nil || !replayedTask.Replayed || replayedTask.Task.ID != created.Task.ID { t.Fatalf("task replay = %+v, %v", replayedTask, err) } buyerID := uuid(1600) deviceID := uuid(1601) seedLifecycleUser( t, db, buyerID, "procurement-buyer", domain.UserRoleBuyer, now, ) seedLifecycleDevice( t, db, deviceID, "procurement-device", buyerID, repeatHex("6"), now, ) lifecycle := &lifecycleFixture{ store: store, db: db, buyerOneID: buyerID, deviceOneID: deviceID, } readyAt := now.Add(3 * time.Second) recordReadyHeartbeat(t, lifecycle, buyerID, deviceID, readyAt) claimed, err := store.ClaimNext( ctx, lifecycleClaimRequest( lifecycle, buyerID, deviceID, 1602, readyAt.Add(time.Second), readyAt.Add(10*time.Minute), readyAt.Add(-time.Minute), ), ) if err != nil || claimed.Task == nil || claimed.Task.ID != created.Task.ID || claimed.Task.Status != domain.TaskStatusClaimed { t.Fatalf("Roubao claim = %+v, %v", claimed, err) } updateRun := freightRun(1102, userID, now.Add(time.Minute)) createAndStartFreightRun( t, store, updateRun, "procurement-source-2", "2", ) changed := freightBatch(1120, "c", "b") changed.Orders[0].CanonicalSHA256 = repeatHex("d") changed.Orders[0].Items[0].Title = "ERP 更新后的标题" changed.Orders[0].Items[0].CanonicalSHA256 = repeatHex("c") if err := store.CompleteFreightSync( ctx, updateRun, changed, now.Add(time.Minute+time.Second), ); err != nil { t.Fatalf("updated freight import error = %v", err) } storedRequest, err := service.Get( ctx, "local-admin", requests[0].ID, ) if err != nil || !storedRequest.SourceChanged || storedRequest.PurchaseTaskID == nil || *storedRequest.PurchaseTaskID != created.Task.ID { t.Fatalf("source changed request = %+v, %v", storedRequest, err) } replayAfterUpdate, err := service.CreateTask( ctx, usecase.CreateProcurementTaskCommand{ CreatorSubject: "local-admin", ActorUserID: userID, RequestID: requests[0].ID, IdempotencyKey: "procurement-task-after-source-update", }, ) if err != nil || !replayAfterUpdate.Replayed || replayAfterUpdate.Task.ID != created.Task.ID { t.Fatalf( "task replay after source update = %+v, %v", replayAfterUpdate, err, ) } storedTask, err := store.GetTaskDetail( ctx, "local-admin", created.Task.ID, ) if err != nil || storedTask.Task.Title != detail.Items[0].Title || storedTask.Task.Title == "ERP 更新后的标题" { t.Fatalf("immutable task = %+v, %v", storedTask.Task, err) } runner, err := migration.New(db) if err != nil { t.Fatalf("migration.New() error = %v", err) } if err := runner.Down(ctx); err != nil { t.Fatalf("date sync migration down: %v", err) } if err := runner.Down(ctx); err == nil { t.Fatal("procurement migration down succeeded with retained requests") } var retained int if err := db.QueryRow( `SELECT count(*) FROM procurement_requests`, ).Scan(&retained); err != nil || retained != 2 { t.Fatalf("retained requests = %d, error = %v", retained, err) } } func TestProcurementBlocksInvalidSourceAndChangedRequest(t *testing.T) { db := openDatabase(t) store, _ := repository.New(db) ctx := context.Background() now := time.Date(2026, 7, 28, 6, 7, 8, 0, time.UTC) userID := uuid(1400) seedFreightUser(t, db, userID, now) run := freightRun(1401, userID, now) createAndStartFreightRun(t, store, run, "blocked-source-1", "3") batch := freightBatch(1410, "a", "b") batch.Orders[0].Items[0].SKU = "" batch.Orders[0].Items[0].CanonicalSHA256 = repeatHex("7") batch.Orders[0].CanonicalSHA256 = repeatHex("8") if err := store.CompleteFreightSync( ctx, run, batch, now.Add(time.Second), ); err != nil { t.Fatalf("CompleteFreightSync() error = %v", err) } orders, _ := store.ListFreightOrders(ctx, "local-admin", 10) detail, _ := store.GetFreightOrder(ctx, "local-admin", orders[0].ID) service, _ := usecase.NewProcurementService( store, procurementClock{now: now.Add(2 * time.Second)}, &procurementIDs{next: 1500}, ) blocked, err := service.CreateRequest( ctx, usecase.CreateProcurementRequestCommand{ CreatorSubject: "local-admin", ActorUserID: userID, FreightOrderItemID: detail.Items[0].ID, ConfirmProcurementNeeded: true, }, ) if err != nil || blocked.Request.Status != domain.ProcurementBlocked || blocked.Request.BlockingCode == nil || *blocked.Request.BlockingCode != domain.ProcurementBlockSKURequired { t.Fatalf("blocked request = %+v, %v", blocked, err) } _, err = service.CreateTask( ctx, usecase.CreateProcurementTaskCommand{ CreatorSubject: "local-admin", ActorUserID: userID, RequestID: blocked.Request.ID, IdempotencyKey: "blocked-task", }, ) var typed *usecase.Error if !errors.As(err, &typed) || typed.Code != "PROCUREMENT_STATE_CONFLICT" { t.Fatalf("blocked CreateTask() error = %v", err) } } type procurementClock struct { now time.Time } func (clock procurementClock) Now() time.Time { return clock.now } type procurementIDs struct { next int } func (ids *procurementIDs) NewID() (string, error) { ids.next++ return uuid(ids.next), nil }