//go:build integration // 集成测试:街拍草稿主副图分组需要真实 PostgreSQL。 // // 运行方式(先起库并确保结构已就绪,见 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" "testing" "fashionapi/internal/model" ) // seedStreetDraft 插入一条街拍草稿并返回其 id。 func seedStreetDraft(t *testing.T, repo ReviewRepository) uint32 { t.Helper() ctx := context.Background() db := repo.(*reviewRepository).db d := model.StreetSnapDraft{Title: "主副图测试", Year: 2026, City: "TestCity", Status: model.DraftStatusPending} if err := db.WithContext(ctx).Create(&d).Error; err != nil { t.Fatalf("插入草稿失败: %v", err) } t.Cleanup(func() { db.Exec("DELETE FROM street_snap_draft_images WHERE draft_id = ?", d.ID) db.Exec("DELETE FROM street_snap_drafts WHERE id = ?", d.ID) }) return d.ID } // seedStreetDraftImage 插入一张草稿图并返回其行 id。 func seedStreetDraftImage(t *testing.T, repo ReviewRepository, draftID uint32, name string, order uint32) uint32 { t.Helper() img := model.StreetSnapDraftImage{DraftID: draftID, 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 } // reloadStreetImages 取草稿的全部图(按 sort_order, id 升序),便于断言。 func reloadStreetImages(t *testing.T, repo ReviewRepository, draftID uint32) []model.StreetSnapDraftImage { t.Helper() imgs, err := repo.StreetDraftImages(context.Background(), draftID) if err != nil { t.Fatalf("读草稿图失败: %v", err) } return imgs } // TestStreetMainDetail 覆盖批量并入 / 并入上一张 / 拆出 / 跨草稿拦截四条主线。 func TestStreetMainDetail(t *testing.T) { repo := NewReviewRepository(testDB(t)) ctx := context.Background() draftID := seedStreetDraft(t, repo) m1 := seedStreetDraftImage(t, repo, draftID, "a", 1) d2 := seedStreetDraftImage(t, repo, draftID, "b", 2) d3 := seedStreetDraftImage(t, repo, draftID, "c", 3) if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { t.Fatalf("并入失败: %v", err) } for _, im := range reloadStreetImages(t, repo, draftID) { 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.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { t.Fatalf("重复并入应幂等,实际报错: %v", err) } // 传入主图自身:调用方过滤掉的语义由仓储兜住(id <> main 条件),不报错也不改动主图。 if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m1}); err != nil { t.Fatalf("并入集合含主图自身应被忽略而非报错: %v", err) } for _, im := range reloadStreetImages(t, repo, draftID) { if im.ID == m1 && (im.IsDetail != 0 || im.ParentImageID != 0) { t.Fatalf("主图被自身并入后不应变化") } } // 拆出:把 d3 恢复为主图。 if err := repo.DetachStreetDraftImage(ctx, draftID, d3); err != nil { t.Fatalf("拆出失败: %v", err) } for _, im := range reloadStreetImages(t, repo, draftID) { 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.DetachStreetDraftImage(ctx, draftID, m1); err != nil { // 对主图幂等成功 t.Fatalf("对主图拆出应幂等成功: %v", err) } // 跨草稿拦截:空集合 + 跨草稿主图。 if err := repo.AttachStreetDraftImages(ctx, draftID, m1, nil); !errors.Is(err, ErrInvalidImageGroup) { t.Fatalf("空集合应返回 ErrInvalidImageGroup,实际 %v", err) } otherMain := seedStreetDraftImage(t, repo, seedStreetDraft(t, repo), "x", 1) if err := repo.AttachStreetDraftImages(ctx, draftID, otherMain, []uint32{d2}); !errors.Is(err, ErrInvalidImageGroup) { t.Fatalf("跨草稿主图应被拦截,实际 %v", err) } } // TestStreetMainDetailReparentGrandchildren 并入一张自带副图的主图时,其原副图应改挂到新主图。 func TestStreetMainDetailReparentGrandchildren(t *testing.T) { repo := NewReviewRepository(testDB(t)) ctx := context.Background() draftID := seedStreetDraft(t, repo) m1 := seedStreetDraftImage(t, repo, draftID, "m1", 1) m2 := seedStreetDraftImage(t, repo, draftID, "m2", 2) child := seedStreetDraftImage(t, repo, draftID, "child", 3) if err := repo.AttachStreetDraftImages(ctx, draftID, m2, []uint32{child}); err != nil { t.Fatalf("预置副图失败: %v", err) } if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{m2}); err != nil { t.Fatalf("并入整组失败: %v", err) } for _, im := range reloadStreetImages(t, repo, draftID) { 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) } } } // TestStreetPromoteRebuildsParentRef 晋升时必须把副图的父引用从「草稿行 id」改写为「新正式行 id」。 func TestStreetPromoteRebuildsParentRef(t *testing.T) { repo := NewReviewRepository(testDB(t)) ctx := context.Background() draftID := seedStreetDraft(t, repo) m1 := seedStreetDraftImage(t, repo, draftID, "main", 1) d2 := seedStreetDraftImage(t, repo, draftID, "detail-a", 2) d3 := seedStreetDraftImage(t, repo, draftID, "detail-b", 3) if err := repo.AttachStreetDraftImages(ctx, draftID, m1, []uint32{d2, d3}); err != nil { t.Fatalf("预置主副图失败: %v", err) } snapID, err := repo.SaveStreetSnapFromDraft(ctx, draftID) if err != nil { t.Fatalf("晋升失败: %v", err) } t.Cleanup(func() { db := repo.(*reviewRepository).db db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", snapID) db.Exec("DELETE FROM street_snaps WHERE id = ?", snapID) }) db := repo.(*reviewRepository).db var rows []model.StreetSnapImage if err := db.WithContext(ctx).Where("snap_id = ? AND is_deleted = 0", snapID). Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil { t.Fatalf("读正式图失败: %v", err) } if len(rows) != 3 { t.Fatalf("期望 3 行正式图,实际 %d", len(rows)) } mainRow := rows[0] if mainRow.IsDetail != 0 || mainRow.ParentImageID != 0 { t.Fatalf("首行应是主图,实际 is_detail=%d parent=%d", mainRow.IsDetail, mainRow.ParentImageID) } for _, row := range rows[1:] { if row.IsDetail != 1 { t.Fatalf("第 %d 行应是副图", row.ID) } if row.ParentImageID != mainRow.ID { t.Fatalf("副图 parent 应指向新主图行 id %d,实际 %d", mainRow.ID, row.ParentImageID) } } }