小项类型: - 迁移 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 文件通过。
240 lines
8.6 KiB
Go
240 lines
8.6 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。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("迁移后存在外键悬挂")
|
||
}
|
||
}
|