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。events 被多张子表以级联/置空引用, // 迁移用 writable_schema 就地改写建表语句,本文件的用例守住「数据不丢 + 新 CHECK 生效」。 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 执行 0016 之前的迁移并登记到 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 一样登记,升级时才能只补跑 0016。 if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil { t.Fatalf("登记迁移 %s: %v", n, err) } } } 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 := countRows(t, db, "schema_migrations"); got != 16 { t.Errorf("迁移登记数 = %d,期望 16", got) } 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("迁移后存在外键悬挂") } }