Files
Scoreboard/server/migrations/event/migration_test.go
T
AskaEth 592e47aef7 feat: 小项类型加「个人+团体」+ 报名导入拆团体/个人两部分
小项类型:
- 迁移 0016 给 events.event_type 的 CHECK 增加 individual_team。SQLite 改不了 CHECK,
  而 events 被多张子表引用(含 ON DELETE CASCADE/SET NULL),事务内 DROP 重建会级联删数据,
  故用 writable_schema 就地改写建表语句文本(只追加一个枚举值,'match' 在建表语句里仅出现一次,
  无副作用),零数据搬动
- domain 加 EventTypeIndividualTeam;管理端下拉加「个人+团体」并提示会同时走两条通道;
  guards/rules 的 eventType 白名单同步(否则会被降级成 individual)
- 注意:该类型目前只解决「能被选出来」,双计的算分语义(个人分不计入单位、团体按队内平均排名)
  尚未实现,等口径确认后再动数据模型

报名导入:
- 卡片拆成「个人报名 / 团体报名」两个 tab,小项+组别提到 tab 之上共享
- 团体报名 = 从已配置单位清单里多选(默认全不勾,正好表达「部分单位不参加」),
  可逐单位为登记队员(0 人即纯单位报名),一次提交为多个单位各建一条报名
- 已报名单位仍可覆盖名单,但会提示「提交将清空原名单」,且「全选」只勾启用中且未报名的单位

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 117/117;console 16 文件通过。
2026-09-10 11:19:31 +08:00

240 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("迁移后存在外键悬挂")
}
}