package sqlite_test import ( "bytes" "context" "image" "image/jpeg" "testing" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/platform/assetstore" repository "cmroubao/backend-api/internal/repository/sqlite" "cmroubao/backend-api/internal/usecase" ) func TestProcurementAutomaticallyCopiesAndCanReplaceERPReference( t *testing.T, ) { db := openDatabase(t) repositories, _ := repository.New(db) ctx := context.Background() now := time.Date(2026, 7, 29, 8, 0, 0, 0, time.UTC) userID := uuid(1700) seedFreightUser(t, db, userID, now) run := freightRun(1701, userID, now) createAndStartFreightRun(t, repositories, run, "auto-reference", "1") batch := freightBatch(1710, "a", "b") firstThumb := "190100" secondThumb := "190101" batch.Orders[0].Items[0].ProductThumbRef = &firstThumb batch.Orders[0].Items[1].ProductThumbRef = &secondThumb if err := repositories.CompleteFreightSync( ctx, run, batch, now.Add(time.Second), ); err != nil { t.Fatalf("CompleteFreightSync() error = %v", err) } orders, _ := repositories.ListFreightOrders(ctx, "local-admin", 10) detail, _ := repositories.GetFreightOrder( ctx, "local-admin", orders[0].ID, ) files, err := assetstore.New(t.TempDir()) if err != nil { t.Fatalf("assetstore.New() error = %v", err) } firstImage := saveReadyFreightImage( t, ctx, repositories, files, detail.Items[0], firstThumb, now.Add(2*time.Second), ) ids := &procurementIDs{next: 1800} service, err := usecase.NewProcurementService( repositories, procurementClock{now: now.Add(3 * time.Second)}, ids, usecase.WithProcurementReferenceStore(files), ) if err != nil { t.Fatalf("NewProcurementService() error = %v", err) } command := usecase.CreateProcurementRequestCommand{ CreatorSubject: "local-admin", ActorUserID: userID, FreightOrderItemID: detail.Items[0].ID, ConfirmProcurementNeeded: true, } created, err := service.CreateRequest(ctx, command) if err != nil || created.Request.Status != domain.ProcurementReady || created.Request.ReferenceAssetID == nil || created.Request.ReferenceOrigin == nil || *created.Request.ReferenceOrigin != domain.ProcurementReferenceERP || created.Request.ReferenceSourceImageSHA256 == nil || *created.Request.ReferenceSourceImageSHA256 != firstImage.SHA256 { t.Fatalf("automatic request = %+v, %v", created, err) } autoAsset, err := repositories.GetAsset( ctx, "local-admin", *created.Request.ReferenceAssetID, ) if err != nil || autoAsset.StorageKey == firstImage.StorageKey { t.Fatalf("automatic asset = %+v, %v", autoAsset, err) } assertStoredImageExists(t, ctx, files, firstImage.StorageKey) assertStoredImageExists(t, ctx, files, autoAsset.StorageKey) replayed, err := service.CreateRequest(ctx, command) if err != nil || !replayed.Replayed || replayed.Request.ID != created.Request.ID || replayed.Request.ReferenceAssetID == nil || *replayed.Request.ReferenceAssetID != autoAsset.ID { t.Fatalf("automatic replay = %+v, %v", replayed, err) } var assetCount int if err := db.QueryRow( `SELECT count(*) FROM assets WHERE creator_subject = ?`, "local-admin", ).Scan(&assetCount); err != nil || assetCount != 1 { t.Fatalf("asset count after replay = %d, %v", assetCount, err) } manualID := uuid(1900) manualNormalized := putTestImage(t, ctx, files, manualID) manual := domain.Asset{ ID: manualID, CreatorSubject: "local-admin", Purpose: domain.AssetPurposeTaskReference, MediaType: manualNormalized.MediaType, SizeBytes: manualNormalized.SizeBytes, SHA256: manualNormalized.SHA256, StorageKey: manualNormalized.StorageKey, CreatedAt: now.Add(4 * time.Second), } if _, _, err := repositories.CreateAssetIdempotent( ctx, manual, "manual-reference", repeatHex("a"), ); err != nil { t.Fatalf("CreateAssetIdempotent() error = %v", err) } replaced, err := service.BindReference( ctx, usecase.BindProcurementReferenceCommand{ CreatorSubject: "local-admin", ActorUserID: userID, RequestID: created.Request.ID, ImageAssetID: manual.ID, }, ) if err != nil || replaced.ReferenceOrigin == nil || *replaced.ReferenceOrigin != domain.ProcurementReferenceManual || replaced.ReferenceAssetID == nil || *replaced.ReferenceAssetID != manual.ID { t.Fatalf("manual replacement = %+v, %v", replaced, err) } if _, err := repositories.GetAsset( ctx, "local-admin", autoAsset.ID, ); err == nil { t.Fatal("replaced automatic asset remains in database") } assertStoredImageMissing(t, ctx, files, autoAsset.StorageKey) assertStoredImageExists(t, ctx, files, firstImage.StorageKey) assertStoredImageExists(t, ctx, files, manual.StorageKey) legacy, _ := usecase.NewProcurementService( repositories, procurementClock{now: now.Add(5 * time.Second)}, ids, ) pending, err := legacy.CreateRequest( ctx, usecase.CreateProcurementRequestCommand{ CreatorSubject: "local-admin", ActorUserID: userID, FreightOrderItemID: detail.Items[1].ID, ConfirmProcurementNeeded: true, }, ) if err != nil || pending.Request.Status != domain.ProcurementNeedsImage { t.Fatalf("pending request = %+v, %v", pending, err) } saveReadyFreightImage( t, ctx, repositories, files, detail.Items[1], secondThumb, now.Add(6*time.Second), ) upgraded, err := service.CreateRequest( ctx, usecase.CreateProcurementRequestCommand{ CreatorSubject: "local-admin", ActorUserID: userID, FreightOrderItemID: detail.Items[1].ID, ConfirmProcurementNeeded: true, }, ) if err != nil || !upgraded.Replayed || upgraded.Request.ID != pending.Request.ID || upgraded.Request.Status != domain.ProcurementReady { t.Fatalf("upgraded request = %+v, %v", upgraded, err) } } func saveReadyFreightImage( t *testing.T, ctx context.Context, repositories *repository.Store, files *assetstore.Store, item domain.FreightOrderItem, thumb string, now time.Time, ) domain.FreightItemImage { t.Helper() normalized := putTestImage(t, ctx, files, item.ID) candidate := domain.FreightItemImage{ CreatorSubject: "local-admin", FreightOrderItemID: item.ID, ProductThumbRef: thumb, Status: domain.FreightItemImageReady, MediaType: normalized.MediaType, SizeBytes: normalized.SizeBytes, SHA256: normalized.SHA256, StorageKey: normalized.StorageKey, UpdatedAt: now, } if _, err := repositories.SaveFreightItemImage(ctx, candidate); err != nil { t.Fatalf("SaveFreightItemImage() error = %v", err) } return candidate } func putTestImage( t *testing.T, ctx context.Context, files *assetstore.Store, id string, ) usecase.NormalizedReferenceImage { t.Helper() var content bytes.Buffer if err := jpeg.Encode( &content, image.NewRGBA(image.Rect(0, 0, 8, 6)), nil, ); err != nil { t.Fatalf("jpeg.Encode() error = %v", err) } normalized, err := files.Put( ctx, id, "image/jpeg", bytes.NewReader(content.Bytes()), ) if err != nil { t.Fatalf("files.Put() error = %v", err) } return normalized } func assertStoredImageExists( t *testing.T, ctx context.Context, files *assetstore.Store, key string, ) { t.Helper() content, err := files.Open(ctx, key) if err != nil { t.Fatalf("files.Open(%q) error = %v", key, err) } _ = content.Close() } func assertStoredImageMissing( t *testing.T, ctx context.Context, files *assetstore.Store, key string, ) { t.Helper() if content, err := files.Open(ctx, key); err == nil { _ = content.Close() t.Fatalf("files.Open(%q) succeeded after cleanup", key) } }