package service import ( "context" "testing" "fashionapi/internal/model" "fashionapi/internal/pkg/storage" "fashionapi/internal/repository" ) // fakeMediaRepo 计数固定返回 0:即所有 key 都是孤儿,应被真删(不误删的前提是调用方已软删引用)。 type fakeMediaRepo struct{ repository.MediaRepository } func (fakeMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, error) { return 0, nil } // fakeIngestRepo 仅实现 processMediaCleanup 需要的 MarkDone/MarkFailed(其余由内嵌 nil 接口兜底,不会被调用)。 type fakeIngestRepo struct{ repository.IngestRepository } func (fakeIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil } func (fakeIngestRepo) MarkFailed(ctx context.Context, id uint32, errMsg string) error { return nil } // countingDeleter 包装 LocalUploader,记录被删除的 key,便于断言。 type countingDeleter struct { *storage.LocalUploader deleted []string } func (c *countingDeleter) Delete(ctx context.Context, key string) error { c.deleted = append(c.deleted, key) return c.LocalUploader.Delete(ctx, key) } // TestProcessMediaCleanup 验证「清理S4孤儿图」任务:引用计数为 0 的 key 会被真删。 func TestProcessMediaCleanup(t *testing.T) { dir := t.TempDir() up := storage.NewLocalUploader(dir, "/uploads") del := &countingDeleter{LocalUploader: up} // 预置一个"孤儿"文件,确认它确实存在。 if _, err := up.Upload(context.Background(), "runway/abc.jpg", []byte("x"), "image/jpeg"); err != nil { t.Fatalf("预置文件失败: %v", err) } svc := NewIngestService(fakeIngestRepo{}, nil, fakeMediaRepo{}, up, del, up, "") job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/abc.jpg"]}`} svc.processMediaCleanup(context.Background(), job) if len(del.deleted) != 1 || del.deleted[0] != "runway/abc.jpg" { t.Fatalf("期望删除 1 个孤儿 key,实际 %v", del.deleted) } } // TestProcessMediaCleanupSkipsReferenced 引用计数 > 0 的 key 必须保留(不误删共享 key)。 func TestProcessMediaCleanupSkipsReferenced(t *testing.T) { dir := t.TempDir() up := storage.NewLocalUploader(dir, "/uploads") del := &countingDeleter{LocalUploader: up} // 引用计数恒为 1(仍被其他图集使用),不应真删。 svc := NewIngestService(fakeIngestRepo{}, nil, referencedMediaRepo{}, up, del, up, "") job := model.IngestJob{Kind: model.IngestKindMediaCleanup, Payload: `{"keys":["runway/shared.jpg"]}`} svc.processMediaCleanup(context.Background(), job) if len(del.deleted) != 0 { t.Fatalf("被引用的 key 不应被删除,实际 %v", del.deleted) } } type referencedMediaRepo struct{ repository.MediaRepository } func (referencedMediaRepo) CountImageKeyRefs(ctx context.Context, key string) (int, error) { return 1, nil } // TestIngestRetryBackoff 验证指数退避:30s / 60s / 120s 且封顶 600s。 func TestIngestRetryBackoff(t *testing.T) { cases := map[int]int{1: 30, 2: 60, 3: 120, 10: 600} for attempts, want := range cases { if got := model.IngestRetryBackoff(attempts); got != want { t.Fatalf("IngestRetryBackoff(%d)=%d, want %d", attempts, got, want) } } } // TestDecideIngestAction 入库三分支决策:未命中→新建;命中 pending/published→放弃;命中 rejected→复用重审。 // 这是「驳回不是永久黑名单」这条规则的直接证据。 func TestDecideIngestAction(t *testing.T) { cases := []struct { name string found bool status string want ingestActionKind }{ {"未命中→新建", false, "", ingestCreate}, {"命中 pending→放弃", true, model.StatusPending, ingestSkip}, {"命中 published→放弃", true, model.StatusPublished, ingestSkip}, {"命中 rejected→复用重审", true, model.StatusRejected, ingestReuseRejected}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := decideIngestAction(c.found, c.status); got != c.want { t.Fatalf("decideIngestAction(%v, %q) = %v, want %v", c.found, c.status, got, c.want) } }) } }