## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
882 lines
34 KiB
Go
882 lines
34 KiB
Go
package event
|
||
|
||
import (
|
||
"database/sql"
|
||
"io/fs"
|
||
"path/filepath"
|
||
"sort"
|
||
"testing"
|
||
|
||
_ "modernc.org/sqlite"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
|
||
)
|
||
|
||
// 迁移用例:0016 给 events.event_type 增加 individual_team(writable_schema 就地改写建表语句),
|
||
// 0017 给 score_ledger / event_score_channels 加 count_to_team、给 events 加 avg_mode。
|
||
// 两个用例都守住同一条底线:老库升级后数据一行不少、新定义与默认值生效。
|
||
|
||
func openEventDB(t *testing.T) *sql.DB {
|
||
t.Helper()
|
||
|
||
path := filepath.Join(t.TempDir(), "event.db")
|
||
dsn := "file:" + path +
|
||
"?_pragma=journal_mode(WAL)" +
|
||
"&_pragma=foreign_keys(1)" +
|
||
"&_pragma=busy_timeout(5000)"
|
||
|
||
db, err := sql.Open("sqlite", dsn)
|
||
if err != nil {
|
||
t.Fatalf("打开赛事库: %v", err)
|
||
}
|
||
// 与 store.Open 一致:单连接。迁移改的是连接内的 schema 缓存,必须在这个前提下验证。
|
||
db.SetMaxOpenConns(1)
|
||
t.Cleanup(func() { _ = db.Close() })
|
||
return db
|
||
}
|
||
|
||
// applyBefore 执行给定迁移之前的全部迁移并登记到 schema_migrations,
|
||
// 模拟老库在升级前的状态(store.MigrateEvent 只能补跑未登记的迁移)。
|
||
func applyBefore(t *testing.T, db *sql.DB, name string) {
|
||
t.Helper()
|
||
|
||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
name TEXT PRIMARY KEY,
|
||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
)`); err != nil {
|
||
t.Fatalf("创建 schema_migrations: %v", err)
|
||
}
|
||
fsys, err := fs.Sub(migrations.EventFS, "event")
|
||
if err != nil {
|
||
t.Fatalf("定位迁移目录: %v", err)
|
||
}
|
||
names, err := fs.Glob(fsys, "*.sql")
|
||
if err != nil {
|
||
t.Fatalf("列出迁移: %v", err)
|
||
}
|
||
sort.Strings(names)
|
||
for _, n := range names {
|
||
if n >= name {
|
||
continue
|
||
}
|
||
body, err := fs.ReadFile(fsys, n)
|
||
if err != nil {
|
||
t.Fatalf("读取迁移 %s: %v", n, err)
|
||
}
|
||
if _, err := db.Exec(string(body)); err != nil {
|
||
t.Fatalf("应用迁移 %s: %v", n, err)
|
||
}
|
||
// 与 store.migrate 一样登记,升级时才能只补跑后面的迁移。
|
||
if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil {
|
||
t.Fatalf("登记迁移 %s: %v", n, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// migrationCount 是嵌入的赛事库迁移条数:升级后登记数必须与文件数一致,
|
||
// 新增迁移时这条断言不用改。
|
||
func migrationCount(t *testing.T) int {
|
||
t.Helper()
|
||
|
||
fsys, err := fs.Sub(migrations.EventFS, "event")
|
||
if err != nil {
|
||
t.Fatalf("定位迁移目录: %v", err)
|
||
}
|
||
names, err := fs.Glob(fsys, "*.sql")
|
||
if err != nil {
|
||
t.Fatalf("列出迁移: %v", err)
|
||
}
|
||
return len(names)
|
||
}
|
||
|
||
func countRows(t *testing.T, db *sql.DB, table string) int {
|
||
t.Helper()
|
||
|
||
var n int
|
||
if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
|
||
t.Fatalf("统计 %s: %v", table, err)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// 全量迁移(run 两次验幂等)后,events 应接受 individual_team、拒绝未知取值。
|
||
func TestMigrateEventAcceptsIndividualTeam(t *testing.T) {
|
||
db := openEventDB(t)
|
||
|
||
for i := 1; i <= 2; i++ {
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||
}
|
||
}
|
||
|
||
if _, err := db.Exec(`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`); err != nil {
|
||
t.Fatalf("插入大项: %v", err)
|
||
}
|
||
// 原有三种类型与新增类型都要放行。
|
||
for _, ok := range []string{"individual", "team", "match", "individual_team"} {
|
||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, ?, '合法', ?)`,
|
||
"OK_"+ok, ok); err != nil {
|
||
t.Errorf("event_type %q 应被接受: %v", ok, err)
|
||
}
|
||
}
|
||
for _, bad := range []string{"", "bogus", "Individual_Team"} {
|
||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, ?, '非法', ?)`,
|
||
"BAD_"+bad, bad); err == nil {
|
||
t.Errorf("非法 event_type %q 应被拒绝", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 老库升级:升级前的数据一行都不能少,外键与索引保持有效,新类型可用。
|
||
func TestMigration0016PreservesExistingRows(t *testing.T) {
|
||
db := openEventDB(t)
|
||
applyBefore(t, db, "0016")
|
||
|
||
stmts := []string{
|
||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||
`INSERT INTO events(sport_id, code, name, event_type, venue, remark)
|
||
VALUES (1, 'E1', '100米跑', 'individual', '田径场', '备注')`,
|
||
`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E2', '团体引体向上', 'team')`,
|
||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
|
||
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
|
||
`INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`,
|
||
`INSERT INTO records(event_id, level, holder_name) VALUES (1, 'meet', '张三')`,
|
||
`INSERT INTO record_breaks(event_id, entry_id, level) VALUES (1, 1, 'meet')`,
|
||
`INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||
VALUES (1, 1, 1, 1, 'result', 1, 10)`,
|
||
`INSERT INTO point_adjustments(team_id, delta, category, reason, event_id)
|
||
VALUES (1, 5, 'bonus', '测试', 1)`,
|
||
`INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json)
|
||
VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`,
|
||
}
|
||
for _, s := range stmts {
|
||
if _, err := db.Exec(s); err != nil {
|
||
t.Fatalf("造数据 %q: %v", s, err)
|
||
}
|
||
}
|
||
|
||
// 走真实升级路径:store.MigrateEvent 只补跑未登记的 0016(及其后的迁移)。
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("升级迁移: %v", err)
|
||
}
|
||
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
|
||
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
|
||
}
|
||
|
||
for table, want := range map[string]int{
|
||
"events": 2, "event_categories": 1, "event_score_channels": 1, "entries": 1,
|
||
"results": 1, "records": 1, "record_breaks": 1, "score_ledger": 1,
|
||
"point_adjustments": 1, "result_revisions": 2,
|
||
} {
|
||
if got := countRows(t, db, table); got != want {
|
||
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
||
}
|
||
}
|
||
|
||
var (
|
||
id int64
|
||
code string
|
||
et string
|
||
venue sql.NullString
|
||
remark sql.NullString
|
||
)
|
||
if err := db.QueryRow(`SELECT id, code, event_type, venue, remark FROM events WHERE id = 1`).
|
||
Scan(&id, &code, &et, &venue, &remark); err != nil {
|
||
t.Fatalf("读 events: %v", err)
|
||
}
|
||
if id != 1 || code != "E1" || et != "individual" || !venue.Valid || !remark.Valid {
|
||
t.Errorf("events 未原样保留: id=%d code=%q type=%q venue=%v remark=%v", id, code, et, venue, remark)
|
||
}
|
||
if got := countRows(t, db, "events"); got != 2 {
|
||
t.Errorf("events 行数 = %d,期望 2", got)
|
||
}
|
||
// 被 SET NULL / CASCADE 波及的两列必须回填。
|
||
var linked int
|
||
if err := db.QueryRow(`SELECT count(*) FROM score_ledger WHERE event_id = 1`).Scan(&linked); err != nil {
|
||
t.Fatalf("查 score_ledger: %v", err)
|
||
}
|
||
if linked != 1 {
|
||
t.Errorf("score_ledger.event_id 未回填,命中 %d 行", linked)
|
||
}
|
||
if err := db.QueryRow(`SELECT count(*) FROM point_adjustments WHERE event_id = 1`).Scan(&linked); err != nil {
|
||
t.Fatalf("查 point_adjustments: %v", err)
|
||
}
|
||
if linked != 1 {
|
||
t.Errorf("point_adjustments.event_id 未回填,命中 %d 行", linked)
|
||
}
|
||
if err := db.QueryRow(`SELECT count(*) FROM result_revisions WHERE parent_id = 1`).Scan(&linked); err != nil {
|
||
t.Fatalf("查 result_revisions: %v", err)
|
||
}
|
||
if linked != 1 {
|
||
t.Errorf("result_revisions 父子关系未保留,子行 %d", linked)
|
||
}
|
||
|
||
// 迁移后的连接(单连接池)必须按新定义校验;RESET 没生效时这里会失败。
|
||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type)
|
||
VALUES (1, 'E3', '双计项', 'individual_team')`); err != nil {
|
||
t.Errorf("迁移后 individual_team 应被接受: %v", err)
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type)
|
||
VALUES (1, 'E4', '非法', 'bogus')`); err == nil {
|
||
t.Error("迁移后非法 event_type 应被拒绝")
|
||
}
|
||
// 自增序列延续,避免后续插入撞号。
|
||
var newID int64
|
||
if err := db.QueryRow(`SELECT id FROM events WHERE code = 'E3'`).Scan(&newID); err != nil {
|
||
t.Fatalf("读新项目: %v", err)
|
||
}
|
||
if newID <= 2 {
|
||
t.Errorf("events 自增序列未延续,新 id = %d", newID)
|
||
}
|
||
|
||
// 索引随建表语句一起被保留(writable_schema 不改索引)。
|
||
var indexCount int
|
||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN
|
||
('idx_events_sport_id', 'idx_events_status')`).Scan(&indexCount); err != nil {
|
||
t.Fatalf("查索引: %v", err)
|
||
}
|
||
if indexCount != 2 {
|
||
t.Errorf("events 索引缺失,命中 %d 个", indexCount)
|
||
}
|
||
// 子表外键仍然生效。
|
||
if _, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id) VALUES (999, 1, 1)`); err == nil {
|
||
t.Error("entries.event_id 外键应仍然生效")
|
||
}
|
||
rows, err := db.Query(`PRAGMA foreign_key_check`)
|
||
if err != nil {
|
||
t.Fatalf("外键检查: %v", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
if rows.Next() {
|
||
t.Error("迁移后存在外键悬挂")
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 0017 迁移:score_ledger 加 count_to_team(重建表)、event_score_channels 加同名字段、
|
||
// events 加 avg_mode。本文件的用例守住「老数据不丢 + 默认值正确 + 新 CHECK 生效」。
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 全量迁移(run 两次验幂等)后,三张表都应按新定义读写。
|
||
func TestMigrate0017AppliesCountToTeamAndAvgMode(t *testing.T) {
|
||
db := openEventDB(t)
|
||
|
||
for i := 1; i <= 2; i++ {
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||
}
|
||
}
|
||
|
||
stmts := []string{
|
||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||
`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E1', '团体引体向上', 'individual_team')`,
|
||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
|
||
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
|
||
}
|
||
for _, s := range stmts {
|
||
if _, err := db.Exec(s); err != nil {
|
||
t.Fatalf("造数据 %q: %v", s, err)
|
||
}
|
||
}
|
||
|
||
// 未指定时取默认值:通道计入单位总分、平均值口径只算有效成绩。
|
||
var (
|
||
countToTeam int
|
||
avgMode string
|
||
)
|
||
if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil {
|
||
t.Fatalf("读通道: %v", err)
|
||
}
|
||
if countToTeam != 1 {
|
||
t.Errorf("通道 count_to_team 默认值 = %d,期望 1", countToTeam)
|
||
}
|
||
if err := db.QueryRow(`SELECT avg_mode FROM events WHERE id = 1`).Scan(&avgMode); err != nil {
|
||
t.Fatalf("读小项: %v", err)
|
||
}
|
||
if avgMode != "valid" {
|
||
t.Errorf("avg_mode 默认值 = %q,期望 valid", avgMode)
|
||
}
|
||
|
||
// 显式写:个人通道不计入单位总分。
|
||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team = 0 WHERE event_id = 1`); err != nil {
|
||
t.Fatalf("改通道开关: %v", err)
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, avg_mode)
|
||
VALUES (1, 'E2', '团体引体向上', 'individual_team', 'all')`); err != nil {
|
||
t.Fatalf("写入 avg_mode=all: %v", err)
|
||
}
|
||
// 非法取值被 CHECK 挡住。
|
||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team = 2 WHERE event_id = 1`); err == nil {
|
||
t.Error("count_to_team=2 应被拒绝")
|
||
}
|
||
if _, err := db.Exec(`UPDATE events SET avg_mode = 'bogus' WHERE id = 1`); err == nil {
|
||
t.Error("avg_mode='bogus' 应被拒绝")
|
||
}
|
||
|
||
// 重建 score_ledger 后:默认计入、单条可置 0、CHECK 生效。
|
||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
|
||
VALUES (1, 1, 'result', 1, 10)`); err != nil {
|
||
t.Fatalf("写入流水: %v", err)
|
||
}
|
||
var ledgerCount int
|
||
if err := db.QueryRow(`SELECT count_to_team FROM score_ledger WHERE id = 1`).Scan(&ledgerCount); err != nil {
|
||
t.Fatalf("读流水: %v", err)
|
||
}
|
||
if ledgerCount != 1 {
|
||
t.Errorf("流水 count_to_team 默认值 = %d,期望 1", ledgerCount)
|
||
}
|
||
if _, err := db.Exec(`UPDATE score_ledger SET count_to_team = 2 WHERE id = 1`); err == nil {
|
||
t.Error("流水 count_to_team=2 应被拒绝")
|
||
}
|
||
}
|
||
|
||
// 老库升级:0016 之前的赛事库补跑 0016 + 0017,行数与索引一个不少,新列取默认值。
|
||
func TestMigration0017PreservesExistingRows(t *testing.T) {
|
||
db := openEventDB(t)
|
||
applyBefore(t, db, "0017")
|
||
|
||
stmts := []string{
|
||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
|
||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
|
||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
|
||
`INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`,
|
||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
|
||
`INSERT INTO score_ledger(team_id, source_type, source_id, points, void_reason)
|
||
VALUES (1, 'adjustment', 1, -3, '人工扣分')`,
|
||
}
|
||
for _, s := range stmts {
|
||
if _, err := db.Exec(s); err != nil {
|
||
t.Fatalf("造数据 %q: %v", s, err)
|
||
}
|
||
}
|
||
|
||
// 走真实升级路径:只补跑未登记的 0017 及其后的迁移。
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("升级迁移: %v", err)
|
||
}
|
||
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
|
||
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
|
||
}
|
||
for table, want := range map[string]int{
|
||
"events": 1, "event_categories": 1, "event_score_channels": 1, "entries": 1,
|
||
"results": 1, "score_ledger": 2,
|
||
} {
|
||
if got := countRows(t, db, table); got != want {
|
||
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
||
}
|
||
}
|
||
|
||
// 历史流水原样保留(含分值、作废原因),新列一律为「计入单位总分」。
|
||
rows, err := db.Query(`SELECT id, points, count_to_team, COALESCE(void_reason, '') FROM score_ledger ORDER BY id`)
|
||
if err != nil {
|
||
t.Fatalf("读流水: %v", err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
wantPoints := []float64{10, -3}
|
||
i := 0
|
||
for rows.Next() {
|
||
var (
|
||
id int64
|
||
points float64
|
||
countToTeam int
|
||
reason string
|
||
)
|
||
if err := rows.Scan(&id, &points, &countToTeam, &reason); err != nil {
|
||
t.Fatalf("扫描流水: %v", err)
|
||
}
|
||
if i >= len(wantPoints) || points != wantPoints[i] {
|
||
t.Fatalf("第 %d 条流水分值 = %v,期望 %v", i, points, wantPoints[i])
|
||
}
|
||
if countToTeam != 1 {
|
||
t.Errorf("第 %d 条流水 count_to_team = %d,期望 1", i, countToTeam)
|
||
}
|
||
i++
|
||
}
|
||
if i != len(wantPoints) {
|
||
t.Fatalf("流水条数 = %d,期望 %d", i, len(wantPoints))
|
||
}
|
||
|
||
// 自增序列延续,避免重建后新流水撞号。
|
||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
|
||
VALUES (1, 'adjustment', 9, 2)`); err != nil {
|
||
t.Fatalf("重建后写入流水: %v", err)
|
||
}
|
||
var newLedgerID int64
|
||
if err := db.QueryRow(`SELECT max(id) FROM score_ledger`).Scan(&newLedgerID); err != nil {
|
||
t.Fatalf("读新流水 id: %v", err)
|
||
}
|
||
if newLedgerID <= int64(len(wantPoints)) {
|
||
t.Errorf("score_ledger 自增序列未延续,新 id = %d", newLedgerID)
|
||
}
|
||
|
||
// 通道与旧小项取默认值。
|
||
var countToTeam int
|
||
if err := db.QueryRow(`SELECT count_to_team FROM event_score_channels WHERE event_id = 1`).Scan(&countToTeam); err != nil {
|
||
t.Fatalf("读通道: %v", err)
|
||
}
|
||
if countToTeam != 1 {
|
||
t.Errorf("老通道 count_to_team = %d,期望 1", countToTeam)
|
||
}
|
||
var avgMode string
|
||
if err := db.QueryRow(`SELECT avg_mode FROM events WHERE id = 1`).Scan(&avgMode); err != nil {
|
||
t.Fatalf("读小项: %v", err)
|
||
}
|
||
if avgMode != "valid" {
|
||
t.Errorf("老小项 avg_mode = %q,期望 valid", avgMode)
|
||
}
|
||
|
||
// 重建表后索引一个不少(0012 补的 (entry_id, voided) 也在内)。
|
||
var indexCount int
|
||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN
|
||
('idx_score_ledger_team_voided', 'idx_score_ledger_source',
|
||
'idx_score_ledger_athlete', 'idx_score_ledger_entry_voided')`).Scan(&indexCount); err != nil {
|
||
t.Fatalf("查索引: %v", err)
|
||
}
|
||
if indexCount != 4 {
|
||
t.Errorf("score_ledger 索引缺失,命中 %d 个", indexCount)
|
||
}
|
||
|
||
// 外键仍然生效,且没有悬挂。
|
||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
|
||
VALUES (999, 'adjustment', 1, 1)`); err == nil {
|
||
t.Error("重建后 score_ledger.team_id 外键应仍然生效")
|
||
}
|
||
fkRows, err := db.Query(`PRAGMA foreign_key_check`)
|
||
if err != nil {
|
||
t.Fatalf("外键检查: %v", err)
|
||
}
|
||
defer func() { _ = fkRows.Close() }()
|
||
if fkRows.Next() {
|
||
t.Error("迁移后存在外键悬挂")
|
||
}
|
||
}
|
||
|
||
// 老库升级:0018 只给 teams 补一列 deleted_at,已有单位与引用它的数据一行不少、默认「未删除」。
|
||
func TestMigration0018AddsDeletedAtForExistingTeams(t *testing.T) {
|
||
db := openEventDB(t)
|
||
applyBefore(t, db, "0018")
|
||
|
||
stmts := []string{
|
||
`INSERT INTO teams(code, name, active) VALUES ('T1', '第一分公司', 0)`,
|
||
// 单位被停用(active = 0)是老库里的状态,升级后不能跟「彻底删除」混为一谈。
|
||
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
|
||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
|
||
}
|
||
for _, s := range stmts {
|
||
if _, err := db.Exec(s); err != nil {
|
||
t.Fatalf("造数据 %q: %v", s, err)
|
||
}
|
||
}
|
||
|
||
var probe sql.NullString
|
||
if err := db.QueryRow(`SELECT deleted_at FROM teams`).Scan(&probe); err == nil {
|
||
t.Fatal("升级前不该有 deleted_at 列")
|
||
}
|
||
|
||
// 走真实升级路径;迁移只登记一次,ALTER TABLE ADD COLUMN 不能重放(重放会报 duplicate column)。
|
||
for i := 1; i <= 2; i++ {
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||
}
|
||
}
|
||
|
||
if got := countRows(t, db, "teams"); got != 1 {
|
||
t.Fatalf("升级后单位行数 = %d,期望 1", got)
|
||
}
|
||
if got := countRows(t, db, "athletes"); got != 1 {
|
||
t.Fatalf("升级后人员行数 = %d,期望 1", got)
|
||
}
|
||
if got := countRows(t, db, "entries"); got != 1 {
|
||
t.Fatalf("升级后报名行数 = %d,期望 1", got)
|
||
}
|
||
|
||
var (
|
||
name string
|
||
active int
|
||
deletedAt sql.NullString
|
||
)
|
||
if err := db.QueryRow(`SELECT name, active, deleted_at FROM teams WHERE id = 1`).
|
||
Scan(&name, &active, &deletedAt); err != nil {
|
||
t.Fatalf("读单位: %v", err)
|
||
}
|
||
if name != "第一分公司" || active != 0 {
|
||
t.Errorf("老单位字段被改动: name=%q active=%d", name, active)
|
||
}
|
||
if deletedAt.Valid {
|
||
t.Errorf("老单位的 deleted_at 应为空(未删除),得到 %q", deletedAt.String)
|
||
}
|
||
|
||
// 新列可写可清,列表过滤与导入复活都靠它。
|
||
if _, err := db.Exec(`UPDATE teams SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
|
||
t.Fatalf("写 deleted_at: %v", err)
|
||
}
|
||
if _, err := db.Exec(`UPDATE teams SET deleted_at = NULL WHERE id = 1`); err != nil {
|
||
t.Fatalf("清 deleted_at: %v", err)
|
||
}
|
||
}
|
||
|
||
// 老库升级:0019 只给 entries 补一列 heat(可空),已有报名一行不少、批次一律为空(未分批)。
|
||
// 批次只是录入 / 展示分组,迁移不能改动任何成绩或积分数据。
|
||
func TestMigration0019AddsHeatForExistingEntries(t *testing.T) {
|
||
db := openEventDB(t)
|
||
applyBefore(t, db, "0019")
|
||
|
||
stmts := []string{
|
||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
|
||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
|
||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
|
||
VALUES (1, 1, 1, '11.58', 11.58, 'published')`,
|
||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
|
||
}
|
||
for _, s := range stmts {
|
||
if _, err := db.Exec(s); err != nil {
|
||
t.Fatalf("造数据 %q: %v", s, err)
|
||
}
|
||
}
|
||
|
||
var probe sql.NullInt64
|
||
if err := db.QueryRow(`SELECT heat FROM entries`).Scan(&probe); err == nil {
|
||
t.Fatal("升级前不该有 heat 列")
|
||
}
|
||
|
||
// 走真实升级路径;ALTER TABLE ADD COLUMN 只登记一次,重放会报 duplicate column。
|
||
for i := 1; i <= 2; i++ {
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||
}
|
||
}
|
||
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
|
||
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
|
||
}
|
||
for table, want := range map[string]int{
|
||
"entries": 1, "results": 1, "score_ledger": 1,
|
||
} {
|
||
if got := countRows(t, db, table); got != want {
|
||
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
||
}
|
||
}
|
||
|
||
// 老报名一律「未分批」,成绩与积分原样保留。
|
||
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil {
|
||
t.Fatalf("读 entries.heat: %v", err)
|
||
}
|
||
if probe.Valid {
|
||
t.Errorf("老报名的 heat 应为空(未分批),得到 %d", probe.Int64)
|
||
}
|
||
var (
|
||
rank int
|
||
state string
|
||
)
|
||
if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
|
||
t.Fatalf("读成绩: %v", err)
|
||
}
|
||
if rank != 1 || state != "published" {
|
||
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
|
||
}
|
||
var points float64
|
||
if err := db.QueryRow(`SELECT points FROM score_ledger WHERE entry_id = 1`).Scan(&points); err != nil {
|
||
t.Fatalf("读流水: %v", err)
|
||
}
|
||
if points != 10 {
|
||
t.Errorf("老积分被改动: %v", points)
|
||
}
|
||
|
||
// 新列可写可清。
|
||
if _, err := db.Exec(`UPDATE entries SET heat = 3 WHERE id = 1`); err != nil {
|
||
t.Fatalf("写 heat: %v", err)
|
||
}
|
||
if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&probe); err != nil {
|
||
t.Fatalf("读 entries.heat: %v", err)
|
||
}
|
||
if !probe.Valid || probe.Int64 != 3 {
|
||
t.Errorf("写入的 heat 未生效: %+v", probe)
|
||
}
|
||
if _, err := db.Exec(`UPDATE entries SET heat = NULL WHERE id = 1`); err != nil {
|
||
t.Fatalf("清 heat: %v", err)
|
||
}
|
||
}
|
||
|
||
// tableColumns 返回 PRAGMA table_info 的列名集合,用于比对迁移新建表的结构。
|
||
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
|
||
t.Helper()
|
||
|
||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||
if err != nil {
|
||
t.Fatalf("读表结构 %s: %v", table, err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
cols := map[string]bool{}
|
||
for rows.Next() {
|
||
var (
|
||
cid int
|
||
name string
|
||
ctype string
|
||
notNull int
|
||
dflt sql.NullString
|
||
pk int
|
||
)
|
||
if err := rows.Scan(&cid, &name, &ctype, ¬Null, &dflt, &pk); err != nil {
|
||
t.Fatalf("扫描表结构 %s: %v", table, err)
|
||
}
|
||
cols[name] = true
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatalf("遍历表结构 %s: %v", table, err)
|
||
}
|
||
return cols
|
||
}
|
||
|
||
// extraColumns 返回实际列里多出来的部分,仅用于列数不符时的报错信息。
|
||
func extraColumns(got map[string]bool, want []string) []string {
|
||
known := make(map[string]bool, len(want))
|
||
for _, c := range want {
|
||
known[c] = true
|
||
}
|
||
var extra []string
|
||
for c := range got {
|
||
if !known[c] {
|
||
extra = append(extra, c)
|
||
}
|
||
}
|
||
sort.Strings(extra)
|
||
return extra
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 0020 迁移:events 加 award_medals,event_score_channels 加 count_to_medals /
|
||
// count_to_team_medals(三列默认 1,与升级前行为一致),新建 channel_ranks 与
|
||
// channel_manual_ranks。本用例守住「老数据不丢 + 默认值正确 + 新表结构 / 唯一约束 /
|
||
// CHECK 生效 + 迁移幂等」这几条底线。
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 老库升级:applyBefore 构造 0020 之前的老库,塞入老数据后走真实升级路径补跑 0020。
|
||
func TestMigration0020ChannelRanksAndMedals(t *testing.T) {
|
||
db := openEventDB(t)
|
||
applyBefore(t, db, "0020")
|
||
|
||
stmts := []string{
|
||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
|
||
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
|
||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
|
||
VALUES (1, 1, 1, '11.58', 11.58, 'published')`,
|
||
}
|
||
for _, s := range stmts {
|
||
if _, err := db.Exec(s); err != nil {
|
||
t.Fatalf("造数据 %q: %v", s, err)
|
||
}
|
||
}
|
||
|
||
// 升级前不该有 0020 的列与表。
|
||
var probe sql.NullInt64
|
||
if err := db.QueryRow(`SELECT award_medals FROM events`).Scan(&probe); err == nil {
|
||
t.Fatal("升级前不该有 events.award_medals 列")
|
||
}
|
||
for _, table := range []string{"channel_ranks", "channel_manual_ranks"} {
|
||
var n int
|
||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&n); err != nil {
|
||
t.Fatalf("查表 %s: %v", table, err)
|
||
}
|
||
if n != 0 {
|
||
t.Fatalf("升级前不该有 %s 表", table)
|
||
}
|
||
}
|
||
|
||
// 走真实升级路径;ADD COLUMN 只登记一次,重放会报 duplicate column。
|
||
for i := 1; i <= 2; i++ {
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||
}
|
||
}
|
||
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
|
||
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
|
||
}
|
||
|
||
// 老数据一行不少,关键字段原样保留。
|
||
for table, want := range map[string]int{"events": 1, "event_score_channels": 1, "results": 1} {
|
||
if got := countRows(t, db, table); got != want {
|
||
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
||
}
|
||
}
|
||
var (
|
||
code string
|
||
status string
|
||
rank int
|
||
state string
|
||
channel int
|
||
)
|
||
if err := db.QueryRow(`SELECT code, status FROM events WHERE id = 1`).Scan(&code, &status); err != nil {
|
||
t.Fatalf("读 events: %v", err)
|
||
}
|
||
if code != "E1" || status != "published" {
|
||
t.Errorf("老小项被改动: code=%q status=%q", code, status)
|
||
}
|
||
if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
|
||
t.Fatalf("读成绩: %v", err)
|
||
}
|
||
if rank != 1 || state != "published" {
|
||
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
|
||
}
|
||
var scope, basis string
|
||
if err := db.QueryRow(`SELECT id, scope, basis FROM event_score_channels WHERE event_id = 1`).
|
||
Scan(&channel, &scope, &basis); err != nil {
|
||
t.Fatalf("读通道: %v", err)
|
||
}
|
||
if scope != "athlete" || basis != "result" {
|
||
t.Errorf("老通道被改动: scope=%q basis=%q", scope, basis)
|
||
}
|
||
|
||
// 三个新列在老行上取默认值 1(与升级前「算奖牌、计入总分与单位奖牌」一致)。
|
||
var awardMedals int
|
||
if err := db.QueryRow(`SELECT award_medals FROM events WHERE id = 1`).Scan(&awardMedals); err != nil {
|
||
t.Fatalf("读 events.award_medals: %v", err)
|
||
}
|
||
if awardMedals != 1 {
|
||
t.Errorf("老小项 award_medals = %d,期望 1", awardMedals)
|
||
}
|
||
var countToMedals, countToTeamMedals int
|
||
if err := db.QueryRow(`SELECT count_to_medals, count_to_team_medals FROM event_score_channels WHERE event_id = 1`).
|
||
Scan(&countToMedals, &countToTeamMedals); err != nil {
|
||
t.Fatalf("读 event_score_channels 奖牌开关: %v", err)
|
||
}
|
||
if countToMedals != 1 || countToTeamMedals != 1 {
|
||
t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals)
|
||
}
|
||
|
||
// 新列可显式置 0;CHECK 拒绝 0/1 之外的取值。
|
||
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE id = 1`); err != nil {
|
||
t.Fatalf("写 award_medals=0: %v", err)
|
||
}
|
||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 0, count_to_team_medals = 0 WHERE event_id = 1`); err != nil {
|
||
t.Fatalf("写通道奖牌开关 0: %v", err)
|
||
}
|
||
if _, err := db.Exec(`UPDATE events SET award_medals = 2 WHERE id = 1`); err == nil {
|
||
t.Error("award_medals=2 应被 CHECK 拒绝")
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, award_medals)
|
||
VALUES (1, 'E2', '非法开关', 'individual', 2)`); err == nil {
|
||
t.Error("插入 award_medals=2 应被 CHECK 拒绝")
|
||
}
|
||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 2 WHERE event_id = 1`); err == nil {
|
||
t.Error("count_to_medals=2 应被 CHECK 拒绝")
|
||
}
|
||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team_medals = 2 WHERE event_id = 1`); err == nil {
|
||
t.Error("count_to_team_medals=2 应被 CHECK 拒绝")
|
||
}
|
||
|
||
// 两张新表存在,列集合与迁移文件一致(少列、多列都会在这里暴露)。
|
||
for _, tc := range []struct {
|
||
table string
|
||
want []string
|
||
}{
|
||
{"channel_ranks", []string{
|
||
"id", "event_id", "category_id", "channel_id", "basis", "scope", "subject_key",
|
||
"entry_id", "result_id", "athlete_id", "team_id", "rank", "points",
|
||
"result_value", "status", "updated_at",
|
||
}},
|
||
{"channel_manual_ranks", []string{
|
||
"id", "event_id", "category_id", "team_id", "scope", "basis", "rank",
|
||
"updated_by", "updated_at",
|
||
}},
|
||
} {
|
||
got := tableColumns(t, db, tc.table)
|
||
if len(got) == 0 {
|
||
t.Errorf("%s 表不存在", tc.table)
|
||
continue
|
||
}
|
||
for _, col := range tc.want {
|
||
if !got[col] {
|
||
t.Errorf("%s 缺列 %s", tc.table, col)
|
||
}
|
||
}
|
||
if len(got) != len(tc.want) {
|
||
t.Errorf("%s 列数 = %d,期望 %d(多余列: %v)", tc.table, len(got), len(tc.want), extraColumns(got, tc.want))
|
||
}
|
||
}
|
||
|
||
// channel_ranks 的唯一索引:同一通道 / 组别 / 主体只能一行。
|
||
var indexCount int
|
||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_channel_ranks_unique'`).Scan(&indexCount); err != nil {
|
||
t.Fatalf("查唯一索引: %v", err)
|
||
}
|
||
if indexCount != 1 {
|
||
t.Errorf("channel_ranks 唯一索引 idx_channel_ranks_unique 缺失")
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
|
||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`); err != nil {
|
||
t.Fatalf("写入 channel_ranks: %v", err)
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
|
||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 2, 8)`); err == nil {
|
||
t.Error("channel_ranks 重复 (channel_id, category_id, subject_key) 应被唯一索引拒绝")
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
|
||
VALUES (1, 1, 1, 'result', 'athlete', 'e:2', 1, 1, 2, 8)`); err != nil {
|
||
t.Errorf("channel_ranks 不同 subject_key 应可写入: %v", err)
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank)
|
||
VALUES (1, 1, 1, 'result', 'bogus', 'x:1', 1)`); err == nil {
|
||
t.Error("channel_ranks.scope='bogus' 应被 CHECK 拒绝")
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank)
|
||
VALUES (1, 1, 1, 'result', 'athlete', 'x:1', 0)`); err == nil {
|
||
t.Error("channel_ranks.rank=0 应被 CHECK 拒绝")
|
||
}
|
||
|
||
// channel_manual_ranks 的 UNIQUE(event_id, category_id, team_id, scope, basis)。
|
||
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
|
||
VALUES (1, 1, 1, 'team', 'manual', 1)`); err != nil {
|
||
t.Fatalf("写入 channel_manual_ranks: %v", err)
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
|
||
VALUES (1, 1, 1, 'team', 'manual', 2)`); err == nil {
|
||
t.Error("channel_manual_ranks 重复 (event_id, category_id, team_id, scope, basis) 应被唯一约束拒绝")
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
|
||
VALUES (1, 1, 1, 'team', 'manual_alt', 2)`); err != nil {
|
||
t.Errorf("channel_manual_ranks 不同 basis 应可写入: %v", err)
|
||
}
|
||
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
|
||
VALUES (1, 1, 1, 'team', 'manual0', 0)`); err == nil {
|
||
t.Error("channel_manual_ranks.rank=0 应被 CHECK 拒绝")
|
||
}
|
||
|
||
// 迁移可重复执行:再跑一次 MigrateEvent 不报错、新表与老表行数不变。
|
||
countsBefore := []int{countRows(t, db, "channel_ranks"), countRows(t, db, "channel_manual_ranks"), countRows(t, db, "events")}
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("重复迁移: %v", err)
|
||
}
|
||
countsAfter := []int{countRows(t, db, "channel_ranks"), countRows(t, db, "channel_manual_ranks"), countRows(t, db, "events")}
|
||
for i, name := range []string{"channel_ranks", "channel_manual_ranks", "events"} {
|
||
if countsBefore[i] != countsAfter[i] {
|
||
t.Errorf("重复迁移 %s 行数 %d → %d", name, countsBefore[i], countsAfter[i])
|
||
}
|
||
}
|
||
}
|