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

244 lines
6.1 KiB
Go
Raw Normal View History

2026-07-29 15:12:05 +08:00
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)
}
}
}
2026-07-29 16:10:45 +08:00
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)
}
}
2026-07-29 15:12:05 +08:00
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)
}