package usecase import ( "bytes" "context" "errors" "io" "strings" "sync" "testing" "cmroubao/backend-api/internal/domain" ) func TestFreightImageCacheIsBestEffortAndCleansReplacedFiles(t *testing.T) { repository := &freightImageRepositoryFake{ jobs: []domain.FreightItemImageJob{ { CreatorSubject: "local-admin", FreightOrderItemID: "00000000-0000-4000-8000-000000000001", ProductThumbRef: "190", }, { CreatorSubject: "local-admin", FreightOrderItemID: "00000000-0000-4000-8000-000000000002", ProductThumbRef: "191", }, }, replaced: "old/image.jpg", } source := &freightImageSourceFake{} store := &freightImageStoreFake{} service, err := NewFreightImageService( repository, source, store, fakeClock{}, ) if err != nil { t.Fatalf("NewFreightImageService() error = %v", err) } if err := service.CacheRun( context.Background(), "local-admin", "00000000-0000-4000-8000-000000000099", ); err != nil { t.Fatalf("CacheRun() error = %v", err) } repository.mu.Lock() defer repository.mu.Unlock() if len(repository.saved) != 2 { t.Fatalf("saved images = %+v", repository.saved) } statusByRef := map[string]domain.FreightItemImage{} for _, image := range repository.saved { statusByRef[image.ProductThumbRef] = image } if statusByRef["190"].Status != domain.FreightItemImageReady || statusByRef["190"].StorageKey != "new/image.jpg" || statusByRef["191"].Status != domain.FreightItemImageMissing || statusByRef["191"].ErrorCode == nil || *statusByRef["191"].ErrorCode != "ERP_IMAGE_NOT_FOUND" { t.Fatalf("saved image statuses = %+v", statusByRef) } store.mu.Lock() defer store.mu.Unlock() if len(store.deleted) != 1 || store.deleted[0] != "old/image.jpg" { t.Fatalf("deleted storage keys = %#v", store.deleted) } } func TestFreightImageOpenDoesNotRevealUnavailableItems(t *testing.T) { repository := &freightImageRepositoryFake{ ready: domain.FreightItemImage{ CreatorSubject: "local-admin", FreightOrderItemID: "00000000-0000-4000-8000-000000000001", Status: domain.FreightItemImageReady, StorageKey: "ready/image.jpg", }, } store := &freightImageStoreFake{ openContent: []byte("normalized-jpeg"), } service, err := NewFreightImageService( repository, &freightImageSourceFake{}, store, fakeClock{}, ) if err != nil { t.Fatalf("NewFreightImageService() error = %v", err) } result, err := service.OpenItemImage( context.Background(), "local-admin", repository.ready.FreightOrderItemID, ) if err != nil { t.Fatalf("OpenItemImage() error = %v", err) } defer result.Content.Close() content, _ := io.ReadAll(result.Content) if string(content) != "normalized-jpeg" { t.Fatalf("image content = %q", content) } if _, err := service.OpenItemImage( context.Background(), "another-subject", repository.ready.FreightOrderItemID, ); err == nil { t.Fatal("cross-subject OpenItemImage() error = nil") } else { var typed *Error if !errors.As(err, &typed) || typed.Code != "FREIGHT_IMAGE_NOT_FOUND" { t.Fatalf("cross-subject error = %v", err) } } } func TestFreightImageDeleteStoredImagesUsesIdempotentStoreCleanup(t *testing.T) { store := &freightImageStoreFake{} service, err := NewFreightImageService( &freightImageRepositoryFake{}, &freightImageSourceFake{}, store, fakeClock{}, ) if err != nil { t.Fatalf("NewFreightImageService() error = %v", err) } service.DeleteStoredImages([]string{ "first/image.jpg", "second/image.jpg", }) store.mu.Lock() defer store.mu.Unlock() if len(store.deleted) != 2 || store.deleted[0] != "first/image.jpg" || store.deleted[1] != "second/image.jpg" { t.Fatalf("deleted storage keys = %#v", store.deleted) } } type freightImageRepositoryFake struct { mu sync.Mutex jobs []domain.FreightItemImageJob saved []domain.FreightItemImage replaced string ready domain.FreightItemImage } func (repository *freightImageRepositoryFake) ListFreightImageJobs( context.Context, string, string, int, ) ([]domain.FreightItemImageJob, error) { return append([]domain.FreightItemImageJob(nil), repository.jobs...), nil } func (repository *freightImageRepositoryFake) SaveFreightItemImage( _ context.Context, image domain.FreightItemImage, ) (*string, error) { repository.mu.Lock() defer repository.mu.Unlock() repository.saved = append(repository.saved, image) if image.Status == domain.FreightItemImageReady && repository.replaced != "" { value := repository.replaced repository.replaced = "" return &value, nil } return nil, nil } func (repository *freightImageRepositoryFake) GetReadyFreightItemImage( _ context.Context, creatorSubject, itemID string, ) (domain.FreightItemImage, error) { if repository.ready.CreatorSubject != creatorSubject || repository.ready.FreightOrderItemID != itemID { return domain.FreightItemImage{}, ErrRepositoryNotFound } return repository.ready, nil } type freightImageSourceFake struct{} func (*freightImageSourceFake) FetchProductImage( _ context.Context, productThumbRef string, ) (FreightSourceImage, error) { if productThumbRef == "191" { return FreightSourceImage{}, domain.ErrFreightImageNotFound } return FreightSourceImage{ Content: io.NopCloser(bytes.NewReader([]byte("source-image"))), MediaType: "image/png", }, nil } type freightImageStoreFake struct { mu sync.Mutex deleted []string openContent []byte } func (*freightImageStoreFake) Put( context.Context, string, string, io.Reader, ) (NormalizedReferenceImage, error) { return NormalizedReferenceImage{ StorageKey: "new/image.jpg", MediaType: "image/jpeg", SizeBytes: 100, SHA256: repeatSHA("a"), }, nil } func (store *freightImageStoreFake) Open( context.Context, string, ) (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(store.openContent)), nil } func (store *freightImageStoreFake) Delete( _ context.Context, storageKey string, ) error { store.mu.Lock() defer store.mu.Unlock() store.deleted = append(store.deleted, storageKey) return nil } func repeatSHA(value string) string { return strings.Repeat(value, 64) }