删除 cmd/dbtool/db_dump.sql:它是陈旧的全库 dump 旧产物(仍含 4 张已删草稿表与旧 schema),与权威的根 db_dump.sql 同名易混,且未被 .gitignore 覆盖;确认代码与文档均无引用,删除比再生成更干净。gofmt 修正 4 个文件(仅空白与注释,无语义变化):runway_image.go 结构体字段对齐、phash_test.go 行尾注释对齐、ingest_repository.go 两行多余空行、street_main_detail_integration_test.go 文档注释中的两个单引号会被 gofmt 改写成弯引号(改述为「空串」)。
321 lines
13 KiB
Go
321 lines
13 KiB
Go
//go:build integration
|
||
|
||
// 集成测试:街拍主副图分组需要真实 PostgreSQL。
|
||
//
|
||
// 单表发布模型下操作对象是正式表本身(street_snaps / street_snap_images),
|
||
// 不再有草稿表;审核通过只改状态、不重建图片,故本文件不再覆盖「晋升重建父引用」。
|
||
//
|
||
// 运行方式(先起库并确保结构已就绪,见 README「数据库」一节):
|
||
//
|
||
// docker compose -f scripts/pgvector/docker-compose.yml up -d
|
||
// go test -tags integration ./internal/repository/ -run TestStreetMainDetail -v
|
||
//
|
||
// 默认 `go test ./...` 不会编译本文件(build tag 隔离)。
|
||
package repository
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"testing"
|
||
|
||
"fashionapi/internal/model"
|
||
)
|
||
|
||
// mainDetailSeedSeq 让每次 seed 出来的实体键(city + year + title)互不相同。
|
||
// 正式表上有部分唯一索引 uq_ss_entity (city, year, COALESCE(title, 空串)) WHERE is_deleted = 0,
|
||
// 若测试沿用同一 city/year/title,第二条记录会被唯一索引拒绝。
|
||
var mainDetailSeedSeq int
|
||
|
||
// seedMainDetailSnap 插入一条街拍正式记录(status=pending)并返回其 id。
|
||
// 命名加 MainDetail 前缀以避免与 publish_status_integration_test.go 的 seedStreetSnap 冲突。
|
||
func seedMainDetailSnap(t *testing.T, repo ReviewRepository) uint32 {
|
||
t.Helper()
|
||
ctx := context.Background()
|
||
db := repo.(*reviewRepository).db
|
||
mainDetailSeedSeq++
|
||
snap := model.StreetSnap{
|
||
Title: "主副图测试",
|
||
Year: 2026,
|
||
City: fmt.Sprintf("TestCity-%d", mainDetailSeedSeq),
|
||
Status: model.StatusPending,
|
||
}
|
||
if err := db.WithContext(ctx).Create(&snap).Error; err != nil {
|
||
t.Fatalf("插入街拍记录失败: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snap.ID)
|
||
db.Exec("DELETE FROM street_snaps WHERE id = ?", snap.ID)
|
||
})
|
||
return snap.ID
|
||
}
|
||
|
||
// seedMainDetailImage 插入一张正式街拍图并返回其行 id。
|
||
func seedMainDetailImage(t *testing.T, repo ReviewRepository, snapID uint32, name string, order uint32) uint32 {
|
||
t.Helper()
|
||
img := model.StreetSnapImage{SnapID: snapID, Image: name + ".jpg", Name: name, SortOrder: order}
|
||
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
|
||
t.Fatalf("插入街拍图失败: %v", err)
|
||
}
|
||
return img.ID
|
||
}
|
||
|
||
// reloadSnapImages 取记录的全部图(按 sort_order, id 升序),便于断言。
|
||
func reloadSnapImages(t *testing.T, repo ReviewRepository, snapID uint32) []model.StreetSnapImage {
|
||
t.Helper()
|
||
imgs, err := repo.StreetRecordImages(context.Background(), snapID)
|
||
if err != nil {
|
||
t.Fatalf("读街拍图失败: %v", err)
|
||
}
|
||
return imgs
|
||
}
|
||
|
||
// TestStreetMainDetail 覆盖批量并入 / 拆出 / 跨记录拦截三条主线。
|
||
func TestStreetMainDetail(t *testing.T) {
|
||
repo := NewReviewRepository(testDB(t))
|
||
ctx := context.Background()
|
||
|
||
snapID := seedMainDetailSnap(t, repo)
|
||
m1 := seedMainDetailImage(t, repo, snapID, "a", 1)
|
||
d2 := seedMainDetailImage(t, repo, snapID, "b", 2)
|
||
d3 := seedMainDetailImage(t, repo, snapID, "c", 3)
|
||
|
||
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
|
||
t.Fatalf("并入失败: %v", err)
|
||
}
|
||
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||
switch im.ID {
|
||
case m1:
|
||
if im.IsDetail != 0 || im.ParentImageID != 0 {
|
||
t.Fatalf("主图应保持 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||
}
|
||
case d2, d3:
|
||
if im.IsDetail != 1 || im.ParentImageID != m1 {
|
||
t.Fatalf("副图应指向 %d,实际 is_detail=%d parent=%d", m1, im.IsDetail, im.ParentImageID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 幂等:重复并入同一批不报错,结果不变。
|
||
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{d2, d3}); err != nil {
|
||
t.Fatalf("重复并入应幂等,实际报错: %v", err)
|
||
}
|
||
|
||
// 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。
|
||
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m1}); err != nil {
|
||
t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err)
|
||
}
|
||
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||
if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
||
t.Fatalf("主图被自身并入后不应变化")
|
||
}
|
||
}
|
||
|
||
// 拆出:把 d3 恢复为主图。
|
||
if err := repo.DetachStreetSnapImage(ctx, snapID, d3); err != nil {
|
||
t.Fatalf("拆出失败: %v", err)
|
||
}
|
||
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||
if im.ID == d3 && (im.IsDetail != 0 || im.ParentImageID != 0) {
|
||
t.Fatalf("拆出后应为 is_detail=0/parent=0,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||
}
|
||
}
|
||
if err := repo.DetachStreetSnapImage(ctx, snapID, m1); err != nil { // 对主图幂等成功
|
||
t.Fatalf("对主图拆出应幂等成功: %v", err)
|
||
}
|
||
|
||
// 跨记录拦截:空集合 + 跨记录主图。
|
||
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) {
|
||
t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err)
|
||
}
|
||
otherMain := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "x", 1)
|
||
if err := repo.AttachStreetSnapImages(ctx, snapID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) {
|
||
t.Fatalf("跨记录主图应被拦截,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// TestStreetMainDetailReparentGrandchildren 并入一张自带副图的主图时,其原副图应改挂到新主图。
|
||
func TestStreetMainDetailReparentGrandchildren(t *testing.T) {
|
||
repo := NewReviewRepository(testDB(t))
|
||
ctx := context.Background()
|
||
|
||
snapID := seedMainDetailSnap(t, repo)
|
||
m1 := seedMainDetailImage(t, repo, snapID, "m1", 1)
|
||
m2 := seedMainDetailImage(t, repo, snapID, "m2", 2)
|
||
child := seedMainDetailImage(t, repo, snapID, "child", 3)
|
||
|
||
if err := repo.AttachStreetSnapImages(ctx, snapID, m2, []uint32{child}); err != nil {
|
||
t.Fatalf("预置副图失败: %v", err)
|
||
}
|
||
if err := repo.AttachStreetSnapImages(ctx, snapID, m1, []uint32{m2}); err != nil {
|
||
t.Fatalf("并入整组失败: %v", err)
|
||
}
|
||
for _, im := range reloadSnapImages(t, repo, snapID) {
|
||
if im.ID == m2 && (im.IsDetail != 1 || im.ParentImageID != m1) {
|
||
t.Fatalf("m2 应成为 m1 的副图,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||
}
|
||
if im.ID == child && (im.IsDetail != 1 || im.ParentImageID != m1) {
|
||
t.Fatalf("child 应改挂到 m1,实际 %d/%d", im.IsDetail, im.ParentImageID)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── image_count 重算(软删不漂移) ─────────────────────────────────────────────
|
||
|
||
// snapImageCount 读某街拍记录当前的 image_count。
|
||
func snapImageCount(t *testing.T, repo ReviewRepository, snapID uint32) uint16 {
|
||
t.Helper()
|
||
var row struct {
|
||
ImageCount uint16 `gorm:"column:image_count"`
|
||
}
|
||
if err := repo.(*reviewRepository).db.WithContext(context.Background()).
|
||
Table("street_snaps").Select("image_count").Where("id = ?", snapID).Scan(&row).Error; err != nil {
|
||
t.Fatalf("读街拍 image_count 失败: %v", err)
|
||
}
|
||
return row.ImageCount
|
||
}
|
||
|
||
// runwayImageCount 读某走秀记录当前的 image_count。
|
||
func runwayImageCount(t *testing.T, repo ReviewRepository, runwayID uint32) uint16 {
|
||
t.Helper()
|
||
var row struct {
|
||
ImageCount uint16 `gorm:"column:image_count"`
|
||
}
|
||
if err := repo.(*reviewRepository).db.WithContext(context.Background()).
|
||
Table("brand_runways").Select("image_count").Where("id = ?", runwayID).Scan(&row).Error; err != nil {
|
||
t.Fatalf("读走秀 image_count 失败: %v", err)
|
||
}
|
||
return row.ImageCount
|
||
}
|
||
|
||
// seedMainDetailRunway 插入一条走秀正式记录(status=pending)并返回其 id。
|
||
// SeasonCode 用递增序号,避开 uq_br_entity (brand_id, season_code, collection_type) 部分唯一索引。
|
||
func seedMainDetailRunway(t *testing.T, repo ReviewRepository) uint32 {
|
||
t.Helper()
|
||
db := repo.(*reviewRepository).db
|
||
mainDetailSeedSeq++
|
||
rw := model.BrandRunway{
|
||
BrandID: 1,
|
||
TitleEn: "主副图计数测试",
|
||
Year: 2026,
|
||
SeasonCode: fmt.Sprintf("MD%d", mainDetailSeedSeq),
|
||
CollectionType: "rtw",
|
||
Status: model.StatusPending,
|
||
}
|
||
if err := db.WithContext(context.Background()).Create(&rw).Error; err != nil {
|
||
t.Fatalf("插入走秀记录失败: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", rw.ID)
|
||
db.Exec("DELETE FROM brand_runways WHERE id = ?", rw.ID)
|
||
})
|
||
return rw.ID
|
||
}
|
||
|
||
// seedMainDetailRunwayImage 插入一张走秀图(isDetail=1 表示细节图)并返回其行 id。
|
||
func seedMainDetailRunwayImage(t *testing.T, repo ReviewRepository, runwayID uint32, name string, order uint32, isDetail uint8) uint32 {
|
||
t.Helper()
|
||
img := model.BrandRunwayImage{
|
||
RunwayID: runwayID, BrandID: 1, Image: name + ".jpg", Name: name,
|
||
SortOrder: order, IsDetail: isDetail,
|
||
}
|
||
if err := repo.(*reviewRepository).db.WithContext(context.Background()).Create(&img).Error; err != nil {
|
||
t.Fatalf("插入走秀图失败: %v", err)
|
||
}
|
||
return img.ID
|
||
}
|
||
|
||
// TestSoftDeleteStreetImageRecomputesCount 软删街拍图片后 image_count 必须按实际存活行**重算**。
|
||
//
|
||
// 对「已被软删」或「不属于该记录」的 imgID 重放一次,计数必须不变:
|
||
// 旧实现是 `image_count - 1`(GREATEST 钳到 0),命中 0 行时仍会递减且永不回补;
|
||
// 单表发布模型删掉了晋升时按实际行重算的自愈路径,漂移会永久留在公开列表卡片的「N 张」上。
|
||
func TestSoftDeleteStreetImageRecomputesCount(t *testing.T) {
|
||
repo := NewReviewRepository(testDB(t))
|
||
ctx := context.Background()
|
||
|
||
snapID := seedMainDetailSnap(t, repo)
|
||
keep := seedMainDetailImage(t, repo, snapID, "keep", 1)
|
||
drop := seedMainDetailImage(t, repo, snapID, "drop", 2)
|
||
|
||
if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil {
|
||
t.Fatalf("软删失败: %v", err)
|
||
}
|
||
if got := snapImageCount(t, repo, snapID); got != 1 {
|
||
t.Fatalf("软删一张后 image_count 应为 1,实际 %d", got)
|
||
}
|
||
|
||
// 重放同一张(已被软删):命中 0 行,计数不得变化。
|
||
if err := repo.SoftDeleteStreetImage(ctx, snapID, drop); err != nil {
|
||
t.Fatalf("重复软删应幂等,实际报错: %v", err)
|
||
}
|
||
if got := snapImageCount(t, repo, snapID); got != 1 {
|
||
t.Fatalf("重复软删不应改变 image_count,实际 %d(旧递减实现会掉到 0)", got)
|
||
}
|
||
|
||
// 不属于该记录的 imgID:同样不得影响本记录计数。
|
||
otherImg := seedMainDetailImage(t, repo, seedMainDetailSnap(t, repo), "other", 1)
|
||
if err := repo.SoftDeleteStreetImage(ctx, snapID, otherImg); err != nil {
|
||
t.Fatalf("跨记录软删不应报错: %v", err)
|
||
}
|
||
if got := snapImageCount(t, repo, snapID); got != 1 {
|
||
t.Fatalf("跨记录 imgID 不应改变 image_count,实际 %d", got)
|
||
}
|
||
|
||
imgs, err := repo.StreetRecordImages(ctx, snapID)
|
||
if err != nil {
|
||
t.Fatalf("读存活图失败: %v", err)
|
||
}
|
||
if len(imgs) != 1 || imgs[0].ID != keep {
|
||
t.Fatalf("应只剩 keep 一行,实际 %+v", imgs)
|
||
}
|
||
}
|
||
|
||
// TestSoftDeleteRunwayImage 走秀 image_count 口径是「主图数」(与入库 imageCount = len(Looks) 一致):
|
||
// 软删细节图不改计数;软删主图才减,且同样是重算(重放 / 跨记录不漂移)。
|
||
func TestSoftDeleteRunwayImage(t *testing.T) {
|
||
repo := NewReviewRepository(testDB(t))
|
||
ctx := context.Background()
|
||
|
||
runwayID := seedMainDetailRunway(t, repo)
|
||
main1 := seedMainDetailRunwayImage(t, repo, runwayID, "main1", 1, 0)
|
||
main2 := seedMainDetailRunwayImage(t, repo, runwayID, "main2", 2, 0)
|
||
detail := seedMainDetailRunwayImage(t, repo, runwayID, "detail1", 3, 1)
|
||
|
||
// 首次软删细节图:主图数仍为 2(若用 COUNT(*) 不排除 is_detail,会错算成 3)。
|
||
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, detail); err != nil {
|
||
t.Fatalf("软删细节图失败: %v", err)
|
||
}
|
||
if got := runwayImageCount(t, repo, runwayID); got != 2 {
|
||
t.Fatalf("image_count 只计主图,应为 2,实际 %d", got)
|
||
}
|
||
|
||
// 软删一张主图 → 1。
|
||
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil {
|
||
t.Fatalf("软删主图失败: %v", err)
|
||
}
|
||
if got := runwayImageCount(t, repo, runwayID); got != 1 {
|
||
t.Fatalf("软删一张主图后应为 1,实际 %d", got)
|
||
}
|
||
|
||
// 重放(已软删)+ 跨记录:计数均不得变化。
|
||
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, main2); err != nil {
|
||
t.Fatalf("重复软删应幂等: %v", err)
|
||
}
|
||
otherMain := seedMainDetailRunwayImage(t, repo, seedMainDetailRunway(t, repo), "x", 1, 0)
|
||
if err := repo.SoftDeleteRunwayImage(ctx, runwayID, otherMain); err != nil {
|
||
t.Fatalf("跨记录软删不应报错: %v", err)
|
||
}
|
||
if got := runwayImageCount(t, repo, runwayID); got != 1 {
|
||
t.Fatalf("重放 / 跨记录不应改变 image_count,实际 %d", got)
|
||
}
|
||
|
||
imgs, err := repo.RecordImages(ctx, runwayID)
|
||
if err != nil {
|
||
t.Fatalf("读存活主图失败: %v", err)
|
||
}
|
||
if len(imgs) != 1 || imgs[0].ID != main1 {
|
||
t.Fatalf("应只剩 main1 一行,实际 %+v", imgs)
|
||
}
|
||
}
|