package event import ( "database/sql" "fmt" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) // 0026:团体报名的「队员身份」(entry_athletes)。 // // 四条底线: // - 老数据零变化:既有表(entries / results / score_ledger / channel_ranks)一行一列都不改, // 新增的只有这张关联表; // - 回填**以号码布为准**、其次「姓名 + 单位」,且只在「同单位同名唯一」时才认,重名与查无此人 // 一律留空(不硬猜:猜错就是把某人的分记到另一个人头上); // - 只碰纯团体报名(athlete_id IS NULL):带运动员的报名自己就是那个人的身份; // - 幂等:重复执行迁移(含把这条 SQL 重放一遍)不报错、不重复回填、不覆盖人工改过的关联。 func TestMigration0026EntryAthletes(t *testing.T) { db := openEventDB(t) applyBefore(t, db, "0026") // 回到「升级前」:库结构到 0025 为止 stmts := []string{ `INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`, `INSERT INTO teams(code, name) VALUES ('B', '第二分公司')`, // 号码布是运动员的唯一标识(0014 上有部分唯一索引)。 `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('张伟', '男', 1, 'A001')`, `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('王五', '男', 1, 'A002')`, `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`, // 同单位两个同名的人:回填不许猜(下面这条报名里写的「李强」认不出是哪个)。 `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('李强', '男', 1, NULL)`, // 另一个单位的运动员:号码布能认到他(号码布不分单位),姓名+单位认不到。 `INSERT INTO athletes(name, gender, team_id, bib) VALUES ('赵六', '女', 2, 'B001')`, `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (1, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, `INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`, // 1) 姓名 + 单位都认得上:张伟 + 王五(顺序也要留住)。 `INSERT INTO entries(event_id, category_id, team_id, member_names, status) VALUES (1, 1, 1, '["张伟","王五"]', 'registered')`, // 2) 号码布优先:写的是 A002(同一个单位的王五)与 B001(另一个单位的赵六)。 // 用队伍名与上一条区分开(一个单位在一个组别只能有一条不分队的团体报名,见 0025)。 `INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad) VALUES (1, 1, 1, '["A002","B001"]', 'registered', '二队')`, // 3) 重名(同单位两个李强):不认。 `INSERT INTO entries(event_id, category_id, team_id, member_names, status) VALUES (1, 1, 2, '["李强"]', 'registered')`, // 4) 查无此人:不认。 `INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad) VALUES (1, 1, 2, '["查无此人"]', 'registered', '二队')`, // 5) 带运动员的报名:本来就有身份,不回填。 `INSERT INTO entries(event_id, category_id, team_id, athlete_id, member_names, status) VALUES (1, 1, 1, 1, '["张伟"]', 'registered')`, // 6) member_names 是坏 JSON(历史上被手工写过非 JSON 文本):按「没有名单」处理,迁移不许失败。 `INSERT INTO entries(event_id, category_id, team_id, member_names, status, squad) VALUES (1, 1, 2, '张伟/李强', 'registered', '三队')`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造老库数据 %q: %v", s, err) } } // 升级前:这张表还不该存在。 if _, err := db.Exec(`SELECT count(*) FROM entry_athletes`); err == nil { t.Fatal("升级前不应存在 entry_athletes 表") } else if !strings.Contains(err.Error(), "entry_athletes") { t.Fatalf("查询报错应点明缺表 entry_athletes,实际 %v", err) } // 既有表在升级前的原始内容:升级后必须逐字节一致。 untouched := []string{ `SELECT id, event_id, category_id, team_id, COALESCE(athlete_id, 0), COALESCE(member_names, ''), COALESCE(bib, ''), COALESCE(seed, ''), COALESCE(status, ''), squad FROM entries ORDER BY id`, } before := make([]string, len(untouched)) for i, q := range untouched { before[i] = tableText(t, db, q) } if err := store.MigrateEvent(db); err != nil { t.Fatalf("升级赛事库: %v", err) } // 回填结果:只有「认得出」的两种情形落行,顺序按 member_names 的书写顺序。 want := map[int64][]string{ 1: {"1:0", "2:1"}, // 张伟、王五 2: {"2:0", "5:1"}, // 号码布 A002 → 王五;号码布 B001 → 赵六(另一个单位也认) 3: {}, // 重名不猜 4: {}, // 查无此人不猜 5: {}, // 带运动员的报名不回填 6: {}, // 坏 JSON:按没有名单处理 } got := entryAthletesText(t, db) for entryID, wantRows := range want { if strings.Join(got[entryID], ",") != strings.Join(wantRows, ",") { t.Errorf("报名 %d 回填出 %v,期望 %v", entryID, got[entryID], wantRows) } } // 老数据零变化:既有表的每一行每一个字段都没动。 for i, q := range untouched { if after := tableText(t, db, q); after != before[i] { t.Fatalf("升级改动了既有数据(第 %d 条对拍):\n之前\n%s之后\n%s", i+1, before[i], after) } } // 表结构:主键 (entry_id, athlete_id) 保证同一条报名里同一个人只有一行。 if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (1, 1, 9)`); err == nil { t.Error("同一条报名里同一个运动员重复应被主键拒绝") } // 外键:报名 / 人员不存在都写不进去。 if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (999, 1)`); err == nil { t.Error("entry_id 外键应拦住不存在的报名") } if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id) VALUES (1, 999)`); err == nil { t.Error("athlete_id 外键应拦住不存在的人员") } // 幂等:再跑一次迁移,内容不变。 if err := store.MigrateEvent(db); err != nil { t.Fatalf("重复升级应无副作用: %v", err) } if after := entryAthletesText(t, db); fmt.Sprint(after) != fmt.Sprint(got) { t.Fatalf("重复迁移改动了队员关联:\n之前 %v\n之后 %v", got, after) } // 迁移被重放(schema_migrations 少一行被补跑)时:已有的关联(含人工确认过的)不被覆盖、 // 不产生重复行(INSERT OR IGNORE 的口径与 0024 的份额回填一致)。 if _, err := db.Exec(`INSERT INTO entry_athletes(entry_id, athlete_id, sort_order) VALUES (3, 3, 7)`); err != nil { t.Fatalf("人工补一条关联: %v", err) } if _, err := db.Exec(readMigration(t, "0026")); err != nil { t.Fatalf("重放 0026: %v", err) } replayed := entryAthletesText(t, db) if rows := replayed[3]; strings.Join(rows, ",") != "3:7" { t.Errorf("重放改动了人工确认的关联(顺序必须原样保留):%v", rows) } if rows := replayed[1]; strings.Join(rows, ",") != "1:0,2:1" { t.Errorf("重放后报名 1 的关联应保持不变、也不重复:%v", rows) } // 级联:报名删掉带走它的队员行;人员被彻底删除也带走(purgeAthlete 走的就是这条外键)。 if _, err := db.Exec(`DELETE FROM entries WHERE id = 2`); err != nil { t.Fatalf("删报名: %v", err) } if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE entry_id = 2`); got != 0 { t.Errorf("报名删掉后队员行应级联清空,还剩 %d 条", got) } if _, err := db.Exec(`DELETE FROM athletes WHERE id = 5`); err != nil { t.Fatalf("删人员: %v", err) } if got := queryInt(t, db, `SELECT count(*) FROM entry_athletes WHERE athlete_id = 5`); got != 0 { t.Errorf("人员删掉后队员行应级联清空,还剩 %d 条", got) } // 外键悬挂检查。 fkRows, err := db.Query(`PRAGMA foreign_key_check`) if err != nil { t.Fatalf("外键检查: %v", err) } defer func() { _ = fkRows.Close() }() if fkRows.Next() { t.Error("迁移后存在外键悬挂") } // 迁移登记数对得上(这条迁移被记账一次)。 if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want { t.Errorf("迁移登记数 = %d,期望 %d", got, want) } } // entryAthletesText 把队员关联按报名归组导成 "%d:%d"(运动员 id:顺序)的稳定文本。 func entryAthletesText(t *testing.T, db *sql.DB) map[int64][]string { t.Helper() out := make(map[int64][]string) rows, err := db.Query(`SELECT entry_id, athlete_id, sort_order FROM entry_athletes ORDER BY entry_id, sort_order, athlete_id`) if err != nil { t.Fatalf("读队员关联: %v", err) } defer func() { _ = rows.Close() }() for rows.Next() { var ( entryID, athleteID, order int64 ) if err := rows.Scan(&entryID, &athleteID, &order); err != nil { t.Fatalf("扫描队员关联: %v", err) } out[entryID] = append(out[entryID], fmt.Sprintf("%d:%d", athleteID, order)) } if err := rows.Err(); err != nil { t.Fatalf("遍历队员关联: %v", err) } return out } // tableText 把一条查询的全部行拼成一份对拍文本(列之间用 | 连)。 func tableText(t *testing.T, db *sql.DB, query string) string { t.Helper() rows, err := db.Query(query) if err != nil { t.Fatalf("查询 %q: %v", query, err) } defer func() { _ = rows.Close() }() cols, err := rows.Columns() if err != nil { t.Fatalf("读列名: %v", err) } var b strings.Builder for rows.Next() { cells := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range cells { ptrs[i] = &cells[i] } if err := rows.Scan(ptrs...); err != nil { t.Fatalf("扫描 %q: %v", query, err) } for i, c := range cells { if i > 0 { b.WriteString("|") } switch v := c.(type) { case nil: b.WriteString("") case []byte: b.Write(v) default: fmt.Fprintf(&b, "%v", v) } } b.WriteString("\n") } if err := rows.Err(); err != nil { t.Fatalf("遍历 %q: %v", query, err) } return b.String() }