package event import ( "database/sql" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) // C12:0001–0011 的老库一次性升到最新。 // // 此前每个迁移用例都是 applyBefore 到「目标那一条的前一条」,覆盖的是相邻一两跳; // 真实升级现场拿到的却是很久以前的库(甚至跨好几个版本),一次要连跑 0012~0021 十条, // 其中 0016 会就地改写 events 的建表语句、0017 会重建 score_ledger——这些步骤正是 // 「只在老库上才走得到」的路径。这条用例把这条路整条走一遍: // 数据一行不少、新列取文档写明的默认值、新表建出来、升级后新能力可用、再跑一次也不报错。 func TestMigration0001ToLatestKeepsData(t *testing.T) { db := openEventDB(t) applyBefore(t, db, "0012") // 只应用 0001–0011,回到「很久没升级过」的现场 // 老库数据:只用 0011 之前存在的列(id_card / bib / age / deleted_at / heat / avg_mode 等都还不存在)。 stmts := []string{ `INSERT INTO teams(code, name, short_name, color, sort_order, active) VALUES ('T1', '第一分公司', '一司', '#FF0000', 3, 0)`, `INSERT INTO athletes(name, gender, team_id, employee_no, active) VALUES ('张三', '男', 1, 'E001', 1)`, `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 5, 1)`, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, record_eligible, scored, multiplier, status, sort_order, venue, remark) VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'published', 7, '田径场', '备注')`, `INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order) VALUES (1, '男子', 3, '男', 18, 35, 9)`, `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) VALUES (1, 'athlete', 'result', 1, 0)`, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, status) VALUES (1, 1, 1, 1, 'registered')`, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, is_tie, status, state) VALUES (1, 1, 1, '11.58', 11.58, 0, 'ok', 'published')`, `INSERT INTO records(event_id, level, result_value, holder_name, is_current, source) VALUES (1, 'meet', 11.2, '李四', 1, 'history')`, `INSERT INTO record_breaks(event_id, entry_id, level, result_value, bonus_points, state) VALUES (1, 1, 'meet', 11.2, 5, 'approved')`, `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, voided, void_reason) VALUES (1, 'adjustment', 1, -3, 1, '人工扣分')`, `INSERT INTO point_adjustments(team_id, delta, category, reason, event_id, voided) VALUES (1, 5, 'bonus', '测试奖励', 1, 0)`, `INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json) VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`, `INSERT INTO change_log(entity_type, entity_id, action, payload_json) VALUES ('team', 1, 'upsert', '{}')`, `INSERT INTO audit_logs(user_id, action, entity_type, entity_id, ip, request_id) VALUES (1, 'team.create', 'team', 1, '127.0.0.1', 'req-1')`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造老库数据 %q: %v", s, err) } } // 升级前:新列与新表都不该在。 for _, col := range []string{"id_card", "bib", "age"} { var probe sql.NullString if err := db.QueryRow(`SELECT ` + col + ` FROM athletes`).Scan(&probe); err == nil { t.Fatalf("升级前不该有 athletes.%s 列", col) } } 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) } } // 走真实升级路径:一次补跑 0012~0021 全部十条(含只加一列的 0018/0019/0021)。 if err := store.MigrateEvent(db); err != nil { t.Fatalf("从 0001–0011 直接升到最新失败: %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{ "teams": 1, "athletes": 1, "sports": 1, "events": 1, "event_categories": 1, "event_score_channels": 1, "entries": 1, "results": 1, "records": 1, "record_breaks": 1, "score_ledger": 2, "point_adjustments": 1, "result_revisions": 2, "change_log": 1, "audit_logs": 1, } { if got := countRows(t, db, table); got != want { t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want) } } // 项目里的积分模板由 0011 种下,升级不能把它冲掉(新库现场靠它算分)。 if got := countRows(t, db, "scoring_profiles"); got != 2 { t.Errorf("scoring_profiles 行数 = %d,期望 2(0011 种下的两个模板)", got) } if got := countRows(t, db, "scoring_profile_rules"); got != 12 { t.Errorf("scoring_profile_rules 行数 = %d,期望 12", got) } // ② 老行上的关键取值原样保留。 var ( teamName, teamColor string teamActive, teamSortOrder int ) if err := db.QueryRow(`SELECT name, COALESCE(color, ''), active, sort_order FROM teams WHERE id = 1`). Scan(&teamName, &teamColor, &teamActive, &teamSortOrder); err != nil { t.Fatalf("读单位: %v", err) } if teamName != "第一分公司" || teamColor != "#FF0000" || teamActive != 0 || teamSortOrder != 3 { t.Errorf("老单位被改动: name=%q color=%q active=%d sort=%d", teamName, teamColor, teamActive, teamSortOrder) } var ( eventName, eventStatus, venue, remark string eventMultiplier float64 eventPublished string ) if err := db.QueryRow(`SELECT name, status, COALESCE(venue, ''), COALESCE(remark, ''), multiplier FROM events WHERE id = 1`). Scan(&eventName, &eventStatus, &venue, &remark, &eventMultiplier); err != nil { t.Fatalf("读小项: %v", err) } eventPublished = eventStatus if eventName != "100米跑" || eventPublished != "published" || venue != "田径场" || remark != "备注" || eventMultiplier != 2 { t.Errorf("老小项被改动: name=%q status=%q venue=%q remark=%q multiplier=%v", eventName, eventPublished, venue, remark, eventMultiplier) } var rank, resultState string var resultValue float64 if err := db.QueryRow(`SELECT rank, state, result_value FROM results WHERE entry_id = 1`). Scan(&rank, &resultState, &resultValue); err != nil { t.Fatalf("读成绩: %v", err) } if rank != "1" || resultState != "published" || resultValue != 11.58 { t.Errorf("老成绩被改动: rank=%s state=%q value=%v", rank, resultState, resultValue) } var ledgerPoints float64 var ledgerVoided int if err := db.QueryRow(`SELECT points, voided FROM score_ledger WHERE source_type = 'adjustment'`). Scan(&ledgerPoints, &ledgerVoided); err != nil { t.Fatalf("读流水: %v", err) } if ledgerPoints != -3 || ledgerVoided != 1 { t.Errorf("老流水被改动: points=%v voided=%d", ledgerPoints, ledgerVoided) } // 重建 score_ledger(0017)之后父子关系与自增序列都要正常。 var childParent sql.NullInt64 if err := db.QueryRow(`SELECT parent_id FROM result_revisions WHERE id = 2`).Scan(&childParent); err != nil { t.Fatalf("读变更历史: %v", err) } if !childParent.Valid || childParent.Int64 != 1 { t.Errorf("result_revisions 父子关系未保留: %+v", childParent) } // ③ 每个新列/新表都按文档取默认值:0013–0019 是可空列(老行必须为空), // 0017 与 0020 的开关默认 1(与升级前「算分、算奖牌、计入总分」的行为一致)。 var ( idCard, bib sql.NullString age sql.NullInt64 heat sql.NullInt64 ) if err := db.QueryRow(`SELECT id_card, bib, age FROM athletes WHERE id = 1`). Scan(&idCard, &bib, &age); err != nil { t.Fatalf("读人员新列: %v", err) } if idCard.Valid || bib.Valid || age.Valid { t.Errorf("老人员的新列应全为空: idCard=%v bib=%v age=%v", idCard, bib, age) } var deletedAt sql.NullString if err := db.QueryRow(`SELECT deleted_at FROM teams WHERE id = 1`).Scan(&deletedAt); err != nil { t.Fatalf("读 deleted_at: %v", err) } if deletedAt.Valid { t.Errorf("老单位的 deleted_at 应为空: %q", deletedAt.String) } if err := db.QueryRow(`SELECT heat FROM entries WHERE id = 1`).Scan(&heat); err != nil { t.Fatalf("读 heat: %v", err) } if heat.Valid { t.Errorf("老报名的 heat 应为空(未分批): %d", heat.Int64) } var ( avgMode string eventAwardMedals, scored int countToTeam int ) if err := db.QueryRow(`SELECT avg_mode, award_medals, scored FROM events WHERE id = 1`). Scan(&avgMode, &eventAwardMedals, &scored); err != nil { t.Fatalf("读小项新列: %v", err) } if avgMode != "valid" || eventAwardMedals != 1 || scored != 1 { t.Errorf("老小项的新开关不对: avg_mode=%q award_medals=%d scored=%d", avgMode, eventAwardMedals, scored) } 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 ledgerCountToTeam int if err := db.QueryRow(`SELECT count_to_team FROM score_ledger WHERE source_type = 'result'`).Scan(&ledgerCountToTeam); err != nil { t.Fatalf("读老流水: %v", err) } if ledgerCountToTeam != 1 { t.Errorf("重建后的老流水 count_to_team = %d,期望 1", ledgerCountToTeam) } 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("读通道奖牌开关: %v", err) } if countToMedals != 1 || countToTeamMedals != 1 { t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals) } // 0021 的 note 是老库上不存在的列,老登记(这条是 approved 的破纪录)升级后必须是 NULL: // 历史行没有驳回理由,NULL 才是事实,写成空串或默认文案都会让审核清单说谎。 var breakNote sql.NullString if err := db.QueryRow(`SELECT note FROM record_breaks WHERE id = 1`).Scan(&breakNote); err != nil { t.Fatalf("读破纪录登记的 note: %v", err) } if breakNote.Valid { t.Errorf("老破纪录登记的 note 应为 NULL,得到 %q", breakNote.String) } // ④ 新表建出来了,且是空的(升级不该凭空造名次)。 for _, table := range []string{"channel_ranks", "channel_manual_ranks"} { if got := countRows(t, db, table); got != 0 { t.Errorf("%s 升级后应为空表,得到 %d 行", table, got) } } // 0012 补的 (entry_id, voided) 索引与 0013–0015 的唯一索引都必须建在。 var indexCount int if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name IN ('idx_score_ledger_entry_voided', 'idx_athletes_id_card', 'idx_athletes_bib', 'idx_channel_ranks_unique')`).Scan(&indexCount); err != nil { t.Fatalf("查索引: %v", err) } if indexCount != 4 { t.Errorf("升级后索引缺失,命中 %d / 4", indexCount) } // ⑤ 外键与数据一致性没有悬挂(0016 改的是建表语句、0017 重建过表)。 fkRows, err := db.Query(`PRAGMA foreign_key_check`) if err != nil { t.Fatalf("外键检查: %v", err) } defer func() { _ = fkRows.Close() }() if fkRows.Next() { t.Error("跨十条迁移升级后存在外键悬挂") } var fkEnabled int if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&fkEnabled); err != nil { t.Fatalf("读外键开关: %v", err) } if fkEnabled != 1 { t.Error("升级后外键开关应仍然打开") } // ⑥ 升级后的库要能真的用上这批新能力(列建了但写不进去,等于没升)。 if _, err := db.Exec(`UPDATE athletes SET id_card = '110101199001011234', bib = 'A001', age = 30 WHERE id = 1`); err != nil { t.Fatalf("写人员新列: %v", err) } if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, avg_mode, award_medals) VALUES (1, 'E2', '双计项', 'individual_team', 'all', 0)`); err != nil { t.Fatalf("写 0016/0017/0020 的新枚举与新开关: %v", err) } if _, err := db.Exec(`UPDATE entries SET heat = 2 WHERE id = 1`); err != nil { t.Fatalf("写 heat: %v", err) } 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(`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("写 0020 新建的名次表: %v", err) } // 0021 的 note 也要能写能清:列建了却写不进去,等于没升。 if _, err := db.Exec(`UPDATE record_breaks SET state = 'rejected', note = '录像复核未破' WHERE id = 1`); err != nil { t.Fatalf("写 0021 的驳回理由: %v", err) } if _, err := db.Exec(`UPDATE record_breaks SET state = 'approved', note = NULL WHERE id = 1`); err != nil { t.Fatalf("清 0021 的驳回理由: %v", err) } // 新 CHECK 也在:合法的拒绝了非法值才算真的生效。 if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type) VALUES (1, 'E3', '非法', 'bogus')`); err == nil { t.Error("升级后非法 event_type 应被 CHECK 拒绝") } if _, err := db.Exec(`UPDATE events SET avg_mode = 'bogus' WHERE id = 1`); err == nil { t.Error("升级后非法 avg_mode 应被 CHECK 拒绝") } // ⑦ 再跑一次迁移:不报错,也不再改动任何数据。 countsBefore := map[string]int{} for _, table := range []string{"teams", "athletes", "events", "score_ledger", "channel_ranks", "schema_migrations"} { countsBefore[table] = countRows(t, db, table) } if err := store.MigrateEvent(db); err != nil { t.Fatalf("重复迁移: %v", err) } for table, before := range countsBefore { if got := countRows(t, db, table); got != before { t.Errorf("重复迁移改动 %s 行数:%d → %d", table, before, got) } } }