Files
backend_v2/internal/service/ingest_service_test.go
toom1996 c8a70760cc feat: 入库结果落库 + 品牌详情展示更新记录
背景:任务只有 status=done,分不清「新建」「已存在跳过」「已删除跳过」,
品牌更新历史只能在进程日志里翻,日志一滚就没了。

- ingest_jobs 新增 outcome / note / brand_id(迁移 2026-10-25-01)
  outcome 记结果类型,note 记人话细节(新建 N 张图 / 已存在(status=published))
- 提交任务时解出 brand_id 落库,品牌详情页按 (brand_id, id DESC) 直接查
- 处理时优先用 job.brand_id,payload 里的 hashid 只作老任务兜底:
  避免「别的实例盐不同 → 解出错品牌 → 整场 brand not found」再发生
- 品牌编辑页新增「更新记录」区块:时间 / 场次 / 结果 / 明细(含失败原因与尝试次数)
- 测试:fake 仓储补齐新接口,capture 仓储记录 outcome 供断言
2026-10-25 11:06:16 +08:00

116 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, outcome, note string) error {
return nil
}
func (fakeIngestRepo) ListByBrand(ctx context.Context, brandID uint32, limit int) ([]repository.BrandIngestRecord, error) {
return nil, 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, "", nil, nil, nil)
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, "", nil, nil, nil)
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)
}
})
}
}