//go:build integration // 集成测试:入库直写正式表的三条分支(新建 / 放弃 / 复用驳回行)。 package repository import ( "context" "database/sql" "testing" "time" "fashionapi/internal/model" "fashionapi/internal/pkg/phash" ) func newRunwayForIngest(jobID uint32, brandID uint32, seasonCode, collectionType string, cover string) *model.BrandRunway { return &model.BrandRunway{ JobID: jobID, BrandID: brandID, TitleEn: "ingest-test", SeasonCode: seasonCode, CollectionType: collectionType, Cover: cover, ImageCount: 1, Status: model.StatusPending, } } func newRunwayImages(cover string) []model.BrandRunwayImage { return []model.BrandRunwayImage{{BrandID: 1, Image: cover, Name: "Look 1", SortOrder: 1, LookIndex: 1}} } // TestIngestCreatesPendingRecord 新建分支:实体键不存在 → 写入正式表且 status=pending,图片落到正式图片表。 func TestIngestCreatesPendingRecord(t *testing.T) { db := testDB(t) applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") repo := NewIngestRepository(db) ctx := context.Background() const season = "SS97" _, _, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw") if err != nil { t.Fatalf("RunwayEntityState 出错: %v", err) } if found { t.Fatalf("测试前置:实体键 %s 不应已存在", season) } id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(101, 1, season, "rtw", "ingest-ss97.jpg"), newRunwayImages("ingest-ss97.jpg")) if err != nil { t.Fatalf("CreateRunwayWithImages 出错: %v", err) } t.Cleanup(func() { db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id) db.Exec("DELETE FROM brand_runways WHERE id = ?", id) }) var rw model.BrandRunway if err := db.Where("id = ?", id).First(&rw).Error; err != nil { t.Fatalf("读回记录失败: %v", err) } if rw.Status != model.StatusPending { t.Fatalf("新入库记录应为 pending,实际 %s", rw.Status) } gotID, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw") if err != nil { t.Fatalf("RunwayEntityState 出错: %v", err) } if !found || gotID != id || status != model.StatusPending { t.Fatalf("实体键应命中刚建的行,实际 found=%v id=%d status=%s", found, gotID, status) } publicCount := int64(0) db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount) if publicCount != 0 { t.Fatalf("pending 记录不应出现在公开视图,实际 %d", publicCount) } } // TestIngestReusesRejectedRecord 复用分支:实体键命中 rejected → 覆盖内容、软删旧图、写新图、置回 pending。 func TestIngestReusesRejectedRecord(t *testing.T) { db := testDB(t) applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") repo := NewIngestRepository(db) ctx := context.Background() const season = "FW96" oldID, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(90, 1, season, "rtw", "old.jpg"), newRunwayImages("old.jpg")) if err != nil { t.Fatalf("预置记录失败: %v", err) } t.Cleanup(func() { db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", oldID) db.Exec("DELETE FROM brand_runways WHERE id = ?", oldID) }) if err := db.Model(&model.BrandRunway{}).Where("id = ?", oldID). Updates(map[string]any{"status": model.StatusRejected, "reviewer": "admin", "reject_reason": "图片缺失"}).Error; err != nil { t.Fatalf("置为已驳回失败: %v", err) } _, status, found, err := repo.RunwayEntityState(ctx, 1, season, "rtw") if err != nil || !found || status != model.StatusRejected { t.Fatalf("前置:应命中 rejected 行,实际 found=%v status=%s err=%v", found, status, err) } newRW := newRunwayForIngest(91, 1, season, "rtw", "new.jpg") newRW.TitleEn = "ingest-test-reused" if err := repo.ReuseRejectedRunway(ctx, oldID, newRW, newRunwayImages("new.jpg")); err != nil { t.Fatalf("ReuseRejectedRunway 出错: %v", err) } var rw model.BrandRunway if err := db.Where("id = ?", oldID).First(&rw).Error; err != nil { t.Fatalf("读回记录失败: %v", err) } if rw.Status != model.StatusPending { t.Fatalf("复用后应回到 pending,实际 %s", rw.Status) } if rw.TitleEn != "ingest-test-reused" || rw.Cover != "new.jpg" { t.Fatalf("内容应被覆盖,实际 title=%s cover=%s", rw.TitleEn, rw.Cover) } if rw.Reviewer != "" || rw.RejectReason != "" { t.Fatalf("复用应清空审核痕迹,实际 reviewer=%s reason=%s", rw.Reviewer, rw.RejectReason) } var alive []model.BrandRunwayImage if err := db.Where("runway_id = ? AND is_deleted = 0", oldID).Find(&alive).Error; err != nil { t.Fatalf("读图片失败: %v", err) } if len(alive) != 1 || alive[0].Image != "new.jpg" { t.Fatalf("旧图应被软删、只留新图,实际 %d 张", len(alive)) } var oldAlive int64 db.Model(&model.BrandRunwayImage{}).Where("runway_id = ? AND image = ? AND is_deleted = 0", oldID, "old.jpg").Count(&oldAlive) if oldAlive != 0 { t.Fatalf("旧图应被软删") } } // TestIngestReuseRejectedRefusesPublished 复用守卫:实体键状态是读阶段拿到的,而写库发生在整批 // 图片下载/上传之后(可能数十秒窗口)。若该行在这段窗口内已被改成 published,复用必须失败, // 绝不能把它置回 pending——那等于把已发布内容从公开视图上撤下。 func TestIngestReuseRejectedRefusesPublished(t *testing.T) { db := testDB(t) applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") repo := NewIngestRepository(db) ctx := context.Background() const season = "SS95" id, err := repo.CreateRunwayWithImages(ctx, newRunwayForIngest(80, 1, season, "rtw", "pub.jpg"), newRunwayImages("pub.jpg")) if err != nil { t.Fatalf("预置记录失败: %v", err) } t.Cleanup(func() { db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", id) db.Exec("DELETE FROM brand_runways WHERE id = ?", id) }) // 模拟「读阶段之后、写库之前」该行被审核通过。 if err := db.Model(&model.BrandRunway{}).Where("id = ?", id). Update("status", model.StatusPublished).Error; err != nil { t.Fatalf("置为已发布失败: %v", err) } newRW := newRunwayForIngest(81, 1, season, "rtw", "pub2.jpg") newRW.TitleEn = "ingest-test-should-not-apply" if err := repo.ReuseRejectedRunway(ctx, id, newRW, newRunwayImages("pub2.jpg")); err == nil { t.Fatalf("对已发布行复用应报错,实际返回 nil") } var rw model.BrandRunway if err := db.Where("id = ?", id).First(&rw).Error; err != nil { t.Fatalf("读回记录失败: %v", err) } if rw.Status != model.StatusPublished { t.Fatalf("已发布行不得被复用改回 pending,实际 %s", rw.Status) } if rw.TitleEn != "ingest-test" || rw.Cover != "pub.jpg" { t.Fatalf("复用失败时不应覆盖内容,实际 title=%s cover=%s", rw.TitleEn, rw.Cover) } publicCount := int64(0) db.Table("public_brand_runways").Where("id = ?", id).Count(&publicCount) if publicCount != 1 { t.Fatalf("已发布行应仍在公开视图,实际 %d", publicCount) } } // TestDedupIgnoresSoftDeletedImage 去重不得命中已软删的行:复用驳回行时去重先于软删旧图执行, // 若把「即将被软删的旧图」算进比对,重爬到的同一张图会被打上 dup_of=<旧行 id>, // 留痕指向一条随后公开不可见的记录。 // // 用与探测向量完全相同的 phash(距离 0)插入软删行,保证修复前它必然是「最近一条」被返回, // 因此本断言在修复前确定性失败、修复后确定性通过,与库中其它数据无关。 func TestDedupIgnoresSoftDeletedImage(t *testing.T) { db := testDB(t) applyMigration(t, db, "2026-09-22-01-single-table-publish.sql") repo := NewIngestRepository(db) ctx := context.Background() bits := phash.ToVectorBits(^uint64(0)) now := uint32(time.Now().Unix()) row := model.BrandRunwayImage{ RunwayID: 1, BrandID: 1, Image: "ingest/soft-deleted.jpg", Name: "look", SortOrder: 1, IsDeleted: 1, CreatedAt: now, UpdatedAt: now, Phash: sql.NullString{String: bits, Valid: true}, } if err := db.Create(&row).Error; err != nil { t.Fatalf("插入软删图片失败: %v", err) } t.Cleanup(func() { db.Where("id = ?", row.ID).Delete(&model.BrandRunwayImage{}) }) dupID, found, err := repo.FindNearDuplicateImage(ctx, []string{"brand_runway_images"}, bits, phash.DefaultThreshold) if err != nil { t.Fatalf("FindNearDuplicateImage 出错: %v", err) } if found && dupID == row.ID { t.Fatalf("已软删的行(id=%d)不应参与近重复比对", row.ID) } }