Files
backend_v2/internal/repository/street_main_detail_integration_test.go

321 lines
13 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.

//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)
}
}