package event import ( "database/sql" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) // 0021 只给 record_breaks 补一列 note(可空):老库升级后数据一行不丢、老登记取 NULL、 // 新列可写可清,且迁移只登记一次(ALTER TABLE ADD COLUMN 重放会报 duplicate column)。 // // 这一列存在的意义是「驳回 / 作废这条登记的理由」:老库里没有理由,NULL 才是事实, // 所以它不能有 NOT NULL、也不能有默认值(默认值会把历史行写成一条假理由)。 func TestMigration0021AddsRecordBreakNote(t *testing.T) { db := openEventDB(t) applyBefore(t, db, "0021") 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, result_mode, result_type, compare, record_eligible, status) VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 'published')`, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`, `INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, status, state) VALUES (1, 1, 1, '11.58', 11.58, 'ok', 'published')`, `INSERT INTO records(event_id, level, result_value, result_text, holder_name, is_current, source) VALUES (1, 'meet', 11.20, '11.20', '李四', 1, 'history')`, `INSERT INTO record_breaks(event_id, entry_id, level, result_value, result_text, bonus_points, state) VALUES (1, 1, 'meet', 11.58, '11.58', 2, 'approved')`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造数据 %q: %v", s, err) } } // 升级前不该有 note 列。 var probe sql.NullString if err := db.QueryRow(`SELECT note FROM record_breaks`).Scan(&probe); err == nil { t.Fatal("升级前不该有 record_breaks.note 列") } // 走真实升级路径(main.go 导入旧赛事包后补跑的就是它);ADD COLUMN 只登记一次。 for i := 1; i <= 2; i++ { if err := store.MigrateEvent(db); err != nil { t.Fatalf("第 %d 次迁移: %v", i, 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{"records": 1, "record_breaks": 1, "results": 1, "entries": 1} { if got := countRows(t, db, table); got != want { t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want) } } // 老登记的状态与加分原样保留,note 为 NULL(没有理由,不是空串理由)。 var ( state string bonus float64 note sql.NullString text string level string heldBy string ) if err := db.QueryRow(`SELECT state, bonus_points, note FROM record_breaks WHERE id = 1`). Scan(&state, &bonus, ¬e); err != nil { t.Fatalf("读破纪录登记: %v", err) } if state != "approved" || bonus != 2 { t.Errorf("老登记被改动: state=%q bonus=%v", state, bonus) } if note.Valid { t.Errorf("老登记的 note 应为 NULL(历史行没有理由),得到 %q", note.String) } if err := db.QueryRow(`SELECT level, COALESCE(result_text, ''), COALESCE(holder_name, '') FROM records WHERE id = 1`). Scan(&level, &text, &heldBy); err != nil { t.Fatalf("读纪录库: %v", err) } if level != "meet" || text != "11.20" || heldBy != "李四" { t.Errorf("老纪录被改动: level=%q text=%q holder=%q", level, text, heldBy) } // 新列可用:写理由 → 清理由;状态机照旧接受 rejected。 if _, err := db.Exec(`UPDATE record_breaks SET state = 'rejected', note = '录像复核未破' WHERE id = 1`); err != nil { t.Fatalf("驳回老登记: %v", err) } if err := db.QueryRow(`SELECT note FROM record_breaks WHERE id = 1`).Scan(¬e); err != nil { t.Fatalf("读 note: %v", err) } if !note.Valid || note.String != "录像复核未破" { t.Errorf("写入的驳回理由未生效: %+v", note) } if _, err := db.Exec(`UPDATE record_breaks SET state = 'approved', note = NULL WHERE id = 1`); err != nil { t.Fatalf("重新批准并清理由: %v", err) } // 新登记不写 note 也能插(列可空)。 if _, err := db.Exec(`INSERT INTO record_breaks(event_id, entry_id, level, state) VALUES (1, 1, 'city', 'pending')`); err != nil { t.Fatalf("插入未带 note 的新登记: %v", err) } // 非法状态仍被 CHECK 挡住(迁移没有动过状态机)。 if _, err := db.Exec(`UPDATE record_breaks SET state = 'bogus' WHERE id = 1`); err == nil { t.Error("state='bogus' 应被 CHECK 拒绝") } fkRows, err := db.Query(`PRAGMA foreign_key_check`) if err != nil { t.Fatalf("外键检查: %v", err) } defer func() { _ = fkRows.Close() }() if fkRows.Next() { t.Error("升级后存在外键悬挂") } }