177 lines
8.0 KiB
Go
177 lines
8.0 KiB
Go
//go:build integration
|
||
|
||
// 集成测试:实体键上的部分唯一索引必须阻止「同一实体两行」,
|
||
// 同时不阻止「实体键相同但已软删」的行(与 RunwayEntityState 的 is_deleted = 0 口径一致)。
|
||
package repository
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"fashionapi/internal/model"
|
||
)
|
||
|
||
// TestRunwayEntityKeyUnique 同一实体键的第二行应被拒绝;软删后可以再插入。
|
||
func TestRunwayEntityKeyUnique(t *testing.T) {
|
||
db := testDB(t)
|
||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
|
||
repo := NewIngestRepository(db)
|
||
ctx := context.Background()
|
||
|
||
const season = "SS95"
|
||
rw := newRunwayForIngest(1, 1, season, "rtw", "uniq-a.jpg")
|
||
id, err := repo.CreateRunwayWithImages(ctx, rw, newRunwayImages("uniq-a.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)
|
||
})
|
||
|
||
dup := newRunwayForIngest(1, 1, season, "rtw", "uniq-b.jpg")
|
||
if _, err := repo.CreateRunwayWithImages(ctx, dup, newRunwayImages("uniq-b.jpg")); err == nil {
|
||
t.Fatalf("同一实体键的第二行应被唯一索引拒绝,实际插入成功")
|
||
}
|
||
|
||
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
|
||
if err := db.Model(&model.BrandRunway{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
|
||
t.Fatalf("软删失败: %v", err)
|
||
}
|
||
again := newRunwayForIngest(1, 1, season, "rtw", "uniq-c.jpg")
|
||
newID, err := repo.CreateRunwayWithImages(ctx, again, newRunwayImages("uniq-c.jpg"))
|
||
if err != nil {
|
||
t.Fatalf("软删后应可再插入同实体键: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
db.Exec("DELETE FROM brand_runway_images WHERE runway_id = ?", newID)
|
||
db.Exec("DELETE FROM brand_runways WHERE id = ?", newID)
|
||
})
|
||
}
|
||
|
||
// TestStreetEntityKeyUnique 街拍实体键 = city + year + title(2026-09-22-04 细化后)。
|
||
//
|
||
// 为什么键里要有 title:同城同年可以有多个专题(开发库实例 London 2027 Day 2 / Day 3),
|
||
// title 是数据里唯一能区分它们的字段。因此:
|
||
// ① 同城同年**同标题**的第二行必须被拒(实体键仍具备唯一性);
|
||
// ② 同城同年**不同标题**的第二行必须允许(对应「Day 2 / Day 3 分成两个文章」)。
|
||
func TestStreetEntityKeyUnique(t *testing.T) {
|
||
db := testDB(t)
|
||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
|
||
// 04 是替换索引:先 DROP 掉 03 建的 (city, year),再按 (city, year, title) 重建同名索引。
|
||
// 两个都执行才真实还原「迁移链走完」的库结构,也才能验证旧键确实不再生效。
|
||
applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql")
|
||
repo := NewIngestRepository(db)
|
||
ctx := context.Background()
|
||
|
||
const city = "UniqTestCity"
|
||
const year = 1994
|
||
const titleDay2 = "UniqTest Spring 1994 Day 2"
|
||
const titleDay3 = "UniqTest Spring 1994 Day 3"
|
||
|
||
snap := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
|
||
id, err := repo.CreateStreetSnapWithImages(ctx, snap, []model.StreetSnapImage{{Image: "uniq-a.jpg", SortOrder: 1}})
|
||
if err != nil {
|
||
t.Fatalf("首行应能插入: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", id)
|
||
db.Exec("DELETE FROM street_snaps WHERE id = ?", id)
|
||
})
|
||
|
||
// ① 同城同年同标题:实体键命中,唯一索引必须拒绝。
|
||
dup := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
|
||
if _, err := repo.CreateStreetSnapWithImages(ctx, dup, []model.StreetSnapImage{{Image: "uniq-b.jpg", SortOrder: 1}}); err == nil {
|
||
t.Fatalf("同城同年同标题的第二行应被唯一索引拒绝,实际插入成功")
|
||
}
|
||
|
||
// ② 同城同年不同标题:不同专题,必须允许共存。
|
||
// 这一条是本任务的核心:若还原成 (city, year) 的旧键,这里会插入失败。
|
||
other := &model.StreetSnap{JobID: 1, Title: titleDay3, Year: year, City: city, Status: model.StatusPending}
|
||
otherID, err := repo.CreateStreetSnapWithImages(ctx, other, []model.StreetSnapImage{{Image: "uniq-c.jpg", SortOrder: 1}})
|
||
if err != nil {
|
||
t.Fatalf("同城同年不同标题应可插入,实际失败: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", otherID)
|
||
db.Exec("DELETE FROM street_snaps WHERE id = ?", otherID)
|
||
})
|
||
|
||
// 软删首行后,实体键不再占用(与 is_deleted = 0 的部分索引口径一致)。
|
||
if err := db.Model(&model.StreetSnap{}).Where("id = ?", id).Update("is_deleted", 1).Error; err != nil {
|
||
t.Fatalf("软删失败: %v", err)
|
||
}
|
||
again := &model.StreetSnap{JobID: 1, Title: titleDay2, Year: year, City: city, Status: model.StatusPending}
|
||
newID, err := repo.CreateStreetSnapWithImages(ctx, again, []model.StreetSnapImage{{Image: "uniq-d.jpg", SortOrder: 1}})
|
||
if err != nil {
|
||
t.Fatalf("软删后应可再插入同实体键: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
db.Exec("DELETE FROM street_snap_images WHERE snap_id = ?", newID)
|
||
db.Exec("DELETE FROM street_snaps WHERE id = ?", newID)
|
||
})
|
||
}
|
||
|
||
// TestStreetEntityStateMatchesIndexKey 覆盖 StreetSnapEntityState 的查重口径:
|
||
// 它必须与唯一索引 uq_ss_entity 的表达式 COALESCE(title, '') 完全一致,
|
||
// 否则查重会比索引更严(漏判 → 写入注定被索引拒绝的行)或更松(重复内容被当成新实体)。
|
||
func TestStreetEntityStateMatchesIndexKey(t *testing.T) {
|
||
db := testDB(t)
|
||
applyMigration(t, db, "2026-09-22-01-single-table-publish.sql")
|
||
applyMigration(t, db, "2026-09-22-03-entity-key-unique.sql")
|
||
applyMigration(t, db, "2026-09-22-04-street-entity-key-title.sql")
|
||
repo := NewIngestRepository(db)
|
||
ctx := context.Background()
|
||
|
||
const city = "EntityStateTestCity"
|
||
const year = 1996
|
||
|
||
// 种子行:title = Day 2(经 ORM 写入,title 为普通空串语义)。
|
||
snap := &model.StreetSnap{JobID: 1, Title: "EntityState Day 2", Year: year, City: city, Status: model.StatusPending}
|
||
id, err := repo.CreateStreetSnapWithImages(ctx, snap, nil)
|
||
if err != nil {
|
||
t.Fatalf("种子行应能插入: %v", err)
|
||
}
|
||
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", id) })
|
||
|
||
// ① 同城同年**不同 title** → 不命中(同城同年不同专题是两个实体)。
|
||
if _, _, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 3"); err != nil {
|
||
t.Fatalf("查重出错: %v", err)
|
||
} else if found {
|
||
t.Fatalf("不同 title 不应命中既有行,实际命中")
|
||
}
|
||
|
||
// ② 同城同年**同 title** → 命中,且带出正确 id / status。
|
||
hitID, status, found, err := repo.StreetSnapEntityState(ctx, city, year, "EntityState Day 2")
|
||
if err != nil {
|
||
t.Fatalf("查重出错: %v", err)
|
||
}
|
||
if !found || hitID != id || status != model.StatusPending {
|
||
t.Fatalf("同 title 应命中 id=%d status=pending,实际 found=%v id=%d status=%q", id, found, hitID, status)
|
||
}
|
||
|
||
// ③ title 为 NULL 的历史行 + 传入空串 title → 必须命中。
|
||
// 这是 COALESCE(title, '') 与裸 title = ? 唯一的分歧点:裸写法下 NULL 永不等于空串,
|
||
// 这里会误判「未命中」并插入一行与 NULL 行同键(COALESCE 后同为 '')的记录,被索引拒绝。
|
||
// 用原生 SQL 插入,因为 GORM 的 string 字段会写空串而非 NULL。
|
||
var nullID uint32
|
||
if err := db.Raw(
|
||
"INSERT INTO street_snaps (title, year, city, is_deleted, status, created_at, updated_at) "+
|
||
"VALUES (NULL, ?, ?, 0, ?, 0, 0) RETURNING id",
|
||
year, city, model.StatusPending,
|
||
).Scan(&nullID).Error; err != nil {
|
||
t.Fatalf("插入 NULL title 行失败: %v", err)
|
||
}
|
||
t.Cleanup(func() { db.Exec("DELETE FROM street_snaps WHERE id = ?", nullID) })
|
||
|
||
nid, _, nfound, err := repo.StreetSnapEntityState(ctx, city, year, "")
|
||
if err != nil {
|
||
t.Fatalf("查重出错: %v", err)
|
||
}
|
||
if !nfound || nid != nullID {
|
||
t.Fatalf("NULL title 行 + 空串应命中 id=%d,实际 found=%v id=%d", nullID, nfound, nid)
|
||
}
|
||
}
|