package event import ( "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) // 0027:聚合通道的汇总口径放开到第三个取值 worst(按队内最差排名)。 // // 五条底线: // - 老数据零变化:每条老通道的 aggregate_mode 原样保留(0023 的默认 avg 与后配的 sum), // 其它列一列不改、行数一行不少; // - **子表 channel_ranks 的名次一行不少**(这条迁移要重建父表,父表被 DROP 时会级联清空子表, // 必须原样灌回); // - id 不能漂:老通道的 id 是 channel_ranks 的外键,换号就等于把名次全删了; // - 取值放开到 avg / sum / worst,拼错的仍被 CHECK 挡下; // - 幂等:连跑两次不报错、不重复登记。 func TestMigration0027ChannelAggregateModeWorst(t *testing.T) { db := openEventDB(t) applyBefore(t, db, "0027") // 回到「升级前」:库结构到 0026 为止 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, scored, multiplier, status) VALUES (1, 'MARCH', '5 公里越野', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, `INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`, // 三条老通道:缺省 avg、显式 sum、以及一条非聚合通道(它也有这列,同样要原样留着)。 `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) VALUES (1, 'team', 'aggregate', 1, 1, 'avg')`, `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) VALUES (1, 'athlete', 'aggregate', 1, 2, 'sum')`, `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) VALUES (1, 'team', 'manual', 1, 3)`, // 子表里两条名次:重建父表时必须原样回来(挂的是通道 2)。 `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points, result_value) VALUES (1, 1, 2, 'aggregate', 'team', 't:1', 1, 15, 12.5)`, `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points, result_value) VALUES (1, 1, 2, 'aggregate', 'team', 't:2', 2, 10, 13)`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造老库数据 %q: %v", s, err) } } // 升级前:worst 这一取值还不该被接受。 if _, err := db.Exec(`UPDATE event_score_channels SET aggregate_mode = 'worst' WHERE id = 1`); err == nil { t.Fatal("升级前不应接受 aggregate_mode = 'worst'") } if err := store.MigrateEvent(db); err != nil { t.Fatalf("升级赛事库: %v", err) } // 老行逐列原样(id 尤其不能漂:channel_ranks 的外键挂在它上面)。 want := []struct { id int64 scope string basis string mode string sortOrd int }{ {1, "team", "aggregate", "avg", 1}, {2, "athlete", "aggregate", "sum", 2}, {3, "team", "manual", "avg", 3}, } rows, err := db.Query(`SELECT id, scope, basis, aggregate_mode, sort_order FROM event_score_channels ORDER BY id`) if err != nil { t.Fatalf("读取升级后的通道: %v", err) } var seen []struct { id int64 scope string basis string mode string sortOrd int } for rows.Next() { var got struct { id int64 scope string basis string mode string sortOrd int } if err := rows.Scan(&got.id, &got.scope, &got.basis, &got.mode, &got.sortOrd); err != nil { _ = rows.Close() t.Fatalf("扫描通道: %v", err) } seen = append(seen, got) } if err := rows.Err(); err != nil { _ = rows.Close() t.Fatalf("遍历通道: %v", err) } _ = rows.Close() if len(seen) != len(want) { t.Fatalf("通道数 = %d,期望 %d:%+v", len(seen), len(want), seen) } for i := range want { if seen[i] != want[i] { t.Fatalf("第 %d 条通道 = %+v,期望 %+v", i+1, seen[i], want[i]) } } // 子表名次一行不少、内容一字不差。 var ranks int if err := db.QueryRow(`SELECT COUNT(*) FROM channel_ranks`).Scan(&ranks); err != nil { t.Fatalf("统计通道名次: %v", err) } if ranks != 2 { t.Fatalf("channel_ranks 行数 = %d,期望 2(重建父表时被级联清空了?)", ranks) } var subjectKey, basis, scope string var rank int var points, resultValue float64 if err := db.QueryRow(`SELECT subject_key, basis, scope, rank, points, result_value FROM channel_ranks WHERE channel_id = 2 AND rank = 1`). Scan(&subjectKey, &basis, &scope, &rank, &points, &resultValue); err != nil { t.Fatalf("读取保留下来的通道名次: %v", err) } if subjectKey != "t:1" || basis != "aggregate" || scope != "team" || rank != 1 || points != 15 || resultValue != 12.5 { t.Fatalf("通道名次被改动了:%s/%s/%s rank=%d points=%v value=%v", subjectKey, basis, scope, rank, points, resultValue) } // 外键仍然指着同一张表:级联删除还要照旧生效。 var channelRanksDDL string if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_ranks'`).Scan(&channelRanksDDL); err != nil { t.Fatalf("读取 channel_ranks 定义: %v", err) } if !strings.Contains(channelRanksDDL, "REFERENCES event_score_channels(id) ON DELETE CASCADE") { t.Fatalf("channel_ranks 对通道的外键定义被改动了:\n%s", channelRanksDDL) } // 取值放开:worst 能写进去,拼错的仍被挡下。 if _, err := db.Exec(`UPDATE event_score_channels SET aggregate_mode = 'worst' WHERE id = 1`); err != nil { t.Fatalf("升级后应能写入 aggregate_mode = 'worst': %v", err) } if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) VALUES (1, 'athlete', 'manual', 1, 4, 'biggest')`); err == nil { t.Fatal("非法 aggregate_mode 应被 CHECK 拒绝") } // 新增通道时自增号接着往下走(重建表不能把 id 序列重置成 1,那会与既有名次撞号)。 if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order) VALUES (1, 'athlete', 'manual', 1, 4)`); err != nil { t.Fatalf("新增通道: %v", err) } var maxID int64 if err := db.QueryRow(`SELECT MAX(id) FROM event_score_channels`).Scan(&maxID); err != nil { t.Fatalf("读取通道最大 id: %v", err) } if maxID != 4 { t.Fatalf("新增通道的 id = %d,期望 4(自增号应接着老数据往下走)", maxID) } // 幂等:再跑一次不报错、登记仍只有一条。 if err := store.MigrateEvent(db); err != nil { t.Fatalf("重复升级应无副作用: %v", err) } var registered, total int if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE name = '0027_channel_aggregate_mode_worst.sql'`).Scan(®istered); err != nil { t.Fatalf("查询迁移登记: %v", err) } if registered != 1 { t.Fatalf("迁移登记数 = %d,期望 1", registered) } if err := db.QueryRow(`SELECT COUNT(*) FROM event_score_channels`).Scan(&total); err != nil { t.Fatalf("统计通道: %v", err) } if total != 4 { t.Fatalf("重复升级后通道数 = %d,期望 4", total) } } // 重建父表最容易出的事故是「外键与级联被改坏」:空子表也要能照常写名次、照常级联删除。 func TestMigration0027ForeignKeyStillCascades(t *testing.T) { db := openEventDB(t) applyBefore(t, db, "0027") stmts := []string{ `INSERT INTO sports(code, name) VALUES ('SP9', '测试')`, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES ((SELECT id FROM sports WHERE code = 'SP9'), 'X', 'x', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, `INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`, `INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) VALUES (1, 'team', 'aggregate', 1, 1, 'avg')`, `INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points) VALUES (1, 1, 1, 'aggregate', 'team', 't:1', 1, 15)`, } for _, s := range stmts { if _, err := db.Exec(s); err != nil { t.Fatalf("造老库数据 %q: %v", s, err) } } if err := store.MigrateEvent(db); err != nil { t.Fatalf("升级赛事库: %v", err) } // 新口径的通道能照常落库。 if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) VALUES (1, 'athlete', 'aggregate', 1, 2, 'worst')`); err != nil { t.Fatalf("写入 worst 通道: %v", err) } // 级联删除照旧:删掉通道 1,挂它的名次跟着没。 if _, err := db.Exec(`DELETE FROM event_score_channels WHERE id = 1`); err != nil { t.Fatalf("删除通道: %v", err) } var left int if err := db.QueryRow(`SELECT COUNT(*) FROM channel_ranks WHERE channel_id = 1`).Scan(&left); err != nil { t.Fatalf("统计级联后的名次: %v", err) } if left != 0 { t.Fatalf("重建后级联删除失效:通道删了还留着 %d 条名次", left) } }