package service import ( "context" "net/http" "net/http/httptest" "testing" "fashionapi/internal/dto" "fashionapi/internal/model" "fashionapi/internal/pkg/hashid" "fashionapi/internal/pkg/storage" "fashionapi/internal/repository" ) // captureIngestRepo 捕获 processRunway 交给仓储的「待落库」记录与图片行。 // 只实现被测路径会用到的方法,其余由内嵌 nil 接口兜底(不应被调用)。 type captureIngestRepo struct { repository.IngestRepository createdRW *model.BrandRunway createdImages []model.BrandRunwayImage deletedEntity bool // 模拟实体键已软删(删除即永久:应跳过重爬,不重新写入) } func (c *captureIngestRepo) RunwayEntityState(ctx context.Context, brandID uint32, seasonCode, collectionType string) (uint32, string, bool, bool, error) { if c.deletedEntity { return 1, model.StatusPending, true, true, nil // 命中但已软删 } return 0, "", false, false, nil // 实体键未命中 → 新建分支 } func (c *captureIngestRepo) CreateRunwayWithImages(ctx context.Context, rw *model.BrandRunway, imgs []model.BrandRunwayImage) (uint32, error) { c.createdRW = rw c.createdImages = imgs return 1, nil } func (c *captureIngestRepo) FindNearDuplicateImage(ctx context.Context, tables []string, phashBits string, threshold int) (uint32, bool, error) { return 0, false, nil // 近重复不影响本用例 } func (c *captureIngestRepo) MarkDone(ctx context.Context, id uint32) error { return nil } // RepairRunwayDetailParents 在捕获型仓储里无需真正落库回填,直接返回成功(被测路径只关心落库记录)。 func (c *captureIngestRepo) RepairRunwayDetailParents(ctx context.Context, runwayID uint32) error { return nil } // fakeBrandRepo 品牌校验恒通过。 type fakeBrandRepo struct{ repository.BrandRepository } func (fakeBrandRepo) FindByID(ctx context.Context, id uint32) (*model.Brand, error) { return &model.Brand{}, nil } // TestProcessRunwayImageCountEqualsAliveMainRows 回归 image_count 漂移: // // 旧实现取 imageCount = len(p.Looks),但 fetchLookImages 会跳过 look.Main == "" 的 look // 与 sha1 重复的行 —— 实际落库的主图行可能更少。旧流程靠晋升按实际行重算自愈,单表发布后 // 该路径已删除,漂移会永久留在公开卡片的「N 张」角标与热度 SUM(image_count) 上。 // // 本用例断言:入库时交给仓储的 image_count == 实际存活主图行数(is_detail = 0), // 且在数据触发跳过时**严格小于** len(Looks)。旧实现下 image_count 会是 4,确定性失败。 func TestProcessRunwayImageCountEqualsAliveMainRows(t *testing.T) { dir := t.TempDir() local := storage.NewLocalUploader(dir, "/uploads") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/jpeg") // 按路径返回不同内容:Content 不同 → sha1 不同;同路径 → sha1 相同(触发批次内去重)。 _, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path)) })) defer srv.Close() repo := &captureIngestRepo{} s := &IngestService{ repo: repo, brandRepo: fakeBrandRepo{}, uploader: local, local: local, httpClient: srv.Client(), } p := dto.RunwayIngest{ Kind: dto.IngestKindRunway, BrandUID: hashid.Encode(1), TitleEn: "image-count-test", Year: 2027, Season: "spring", CollectionType: "rtw", Looks: []dto.RunwayLook{ // look 1:主图 a + 细节图 a1 → 1 主图 {Main: srv.URL + "/img/a.jpg", Details: []string{srv.URL + "/img/a1.jpg"}}, // look 2:空主图,只有细节图 b1 → 0 主图(旧实现仍按 1 计) {Details: []string{srv.URL + "/img/b1.jpg"}}, // look 3:主图与 look 1 内容相同(sha1 重复)→ 被跳过,0 主图(旧实现仍按 1 计) {Main: srv.URL + "/img/a.jpg"}, // look 4:主图 c → 1 主图 {Main: srv.URL + "/img/c.jpg"}, }, } job := model.IngestJob{ID: 1, Kind: model.IngestKindCrawl} s.processRunway(context.Background(), job, p) if repo.createdRW == nil { t.Fatal("未写入走秀记录") } // 实际落库主图行:a、c → 2 张;细节图 a1、b1 → 2 张。 var actualMain uint16 for _, im := range repo.createdImages { if im.IsDetail == 0 { actualMain++ } } if actualMain != 2 { t.Fatalf("前置:实际主图行应为 2,实际 %d(图片行=%+v)", actualMain, repo.createdImages) } if got := repo.createdRW.ImageCount; got != actualMain { t.Fatalf("入库 image_count 应等于存活主图行数 %d,实际 %d", actualMain, got) } if repo.createdRW.ImageCount >= uint16(len(p.Looks)) { t.Fatalf("本用例数据应让 image_count(%d) 严格小于 len(Looks)=%d,否则测不出漂移", repo.createdRW.ImageCount, len(p.Looks)) } } // TestProcessRunwaySkipsSoftDeletedEntity 回归「删除即永久」: // 实体键已软删(is_deleted=1)的走秀,重爬时不应被重新写入正式表,仅置任务 done。 func TestProcessRunwaySkipsSoftDeletedEntity(t *testing.T) { dir := t.TempDir() local := storage.NewLocalUploader(dir, "/uploads") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write([]byte("fake-bytes-for" + r.URL.Path)) })) defer srv.Close() // deletedEntity=true:模拟实体键已软删,processRunway 应跳过重爬。 repo := &captureIngestRepo{deletedEntity: true} s := &IngestService{ repo: repo, brandRepo: fakeBrandRepo{}, uploader: local, local: local, httpClient: srv.Client(), } p := dto.RunwayIngest{ Kind: dto.IngestKindRunway, BrandUID: hashid.Encode(1), TitleEn: "deleted-entity-test", Year: 2027, Season: "spring", CollectionType: "rtw", Looks: []dto.RunwayLook{{Main: srv.URL + "/img/a.jpg"}}, } job := model.IngestJob{ID: 2, Kind: model.IngestKindCrawl} s.processRunway(context.Background(), job, p) if repo.createdRW != nil { t.Fatal("已软删的实体不应被重新写入正式表") } if len(repo.createdImages) != 0 { t.Fatalf("已软删的实体不应写入任何图片行,实际 %d 张", len(repo.createdImages)) } }