与运动员的真物理删除是**两套相反口径**,有意如此(两个按钮同名,代码与
文档里都做了显著区分):
- 运动员 `?purge=1`:真删人员+报名+成绩+流水并重算积分(上一版 `9d4ee09`)
- 参赛单位 `?purge=1`:只置 `teams.deleted_at` + `active = 0`,让单位从列表里
消失;报名、成绩、积分流水、人工调整、纪录、名下运动员全部原样,
**不触发重算、积分榜不变**(单位下挂着几十号人,真删代价太大)
实现:
- 迁移 0018 `ALTER TABLE teams ADD COLUMN deleted_at TEXT`
- `listTeams` 默认排除已隐藏单位;`?includeDeleted=1` 仅 admin;
`GET /api/teams/{id}` 对已隐藏单位:admin 200 / 非 admin 404
- 审计 `team.purge`(detail 标明是隐藏);成功走 `writeVoid`(无统计对象)
- 报名导入按 `team_code` 命中已隐藏单位时复活(清 deleted_at、置 active=1)
- 故意不过滤的地方都加了注释:榜单补单位名、写路径与审计、导入按 code 查、
赛事包导出、大屏快照、按 team_id JOIN 取历史单位名——过滤会让历史成绩的
单位名变空白或让包的引用悬挂
管理端「参赛单位」行加「彻底删除」按钮 + 危险确认框(如实写明"从列表移除、
数据不受影响、积分榜不会变化"),失败原文展示、404 不降级。
顺带修一处界面文案与行为不符:单位页原写「停用后新报名不再可选该单位」,
但报名选择器其实仍会列出已停用单位供手动勾选(只有「全选」跳过它们),
已按真实行为改写(两处)。
测试:服务端 12 包全绿(新增 team_purge_test.go 与 0018 迁移用例);
前端 172 例全过(新增 team-purge-api.test.ts 6 例)。docs/ 与 devdocs/ 同步。
532 lines
20 KiB
Go
532 lines
20 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)
|
||
}
|
||
}
|