261 lines
10 KiB
Go
261 lines
10 KiB
Go
//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}}
|
||
}
|
||
|
||
// 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 去重只比对**未软删**的行(is_deleted = 0)。
|
||
//
|
||
// 别把它误读成「复用驳回行时不会自我命中」:复用路径里去重发生在软删旧图**之前**,那一刻
|
||
// 旧行仍是 is_deleted = 0、仍会被命中——dup_of 只是给人工看的留痕,不参与任何可见性判定。
|
||
// 本用例锁的是另一条独立保证:**已经**软删的行不再被当作重复参照。
|
||
//
|
||
// 用与探测向量完全相同的 phash(距离 0)插入软删行,保证它若被纳入就必然是「最近一条」,
|
||
// 从而确定性地验证 is_deleted = 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)
|
||
}
|
||
}
|
||
|
||
// TestIngestImageCountMatchesAliveMainRows 入库后 image_count 必须等于该记录存活主图行数(is_detail = 0)。
|
||
//
|
||
// 与 service.processRunway 的 imageCount = countMainImages(images) 同口径,且与审核期
|
||
// SoftDeleteRunwayImage 的重算口径一致:三处必须同源,否则公开卡片「N 张」角标与
|
||
// FeaturedIDs 的 SUM(image_count) 热度会漂移(单表发布删掉了晋升重算的自愈路径)。
|
||
func TestIngestImageCountMatchesAliveMainRows(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 = "IC71"
|
||
rw := newRunwayForIngest(70, 1, season, "rtw", "ic-main-a.jpg")
|
||
rw.ImageCount = 2 // 2 张主图
|
||
imgs := []model.BrandRunwayImage{
|
||
{BrandID: 1, Image: "ic-main-a.jpg", Name: "Look 1", SortOrder: 1, IsDetail: 0},
|
||
{BrandID: 1, Image: "ic-detail-a1.jpg", Name: "Look 1 — Detail 1", SortOrder: 2, IsDetail: 1},
|
||
{BrandID: 1, Image: "ic-main-b.jpg", Name: "Look 2", SortOrder: 3, IsDetail: 0},
|
||
}
|
||
id, err := repo.CreateRunwayWithImages(ctx, rw, imgs)
|
||
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 got struct {
|
||
ImageCount uint16 `gorm:"column:image_count"`
|
||
AliveMain int64 `gorm:"column:alive_main"`
|
||
}
|
||
if err := db.Raw(`SELECT r.image_count,
|
||
(SELECT COUNT(*) FROM brand_runway_images i
|
||
WHERE i.runway_id = r.id AND i.is_deleted = 0 AND i.is_detail = 0) AS alive_main
|
||
FROM brand_runways r WHERE r.id = ?`, id).Scan(&got).Error; err != nil {
|
||
t.Fatalf("读回计数失败: %v", err)
|
||
}
|
||
if int64(got.ImageCount) != got.AliveMain {
|
||
t.Fatalf("入库后 image_count=%d 必须等于存活主图行数 %d", got.ImageCount, got.AliveMain)
|
||
}
|
||
}
|