package httpapi_test import ( "bytes" "context" "io" "log/slog" "net/http" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) // 本文件只覆盖「老库升级自愈」:迁移 0020 只建 channel_ranks、不回填, // 升级后到下一次重算之间奖牌榜会全 0,启动自愈必须在监听端口前把它补回来。 // healResult 与 httpapi.HealResult 对应;测试里用别名只是为了让断言读起来短一点。 type healResult = httpapi.HealResult // healLogger 造一个把日志写进 buffer 的 logger:自愈触发与否既有返回值、也有日志, // 两个都断言,避免「返回值对了但根本没打日志」或反之。 func healLogger(buf *bytes.Buffer) *slog.Logger { return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo})) } // runHeal 以 httpapi.Options 最小形态调用自愈(只有 Event 是必需的)。 func runHeal(t *testing.T, env *testEnv, logs *bytes.Buffer) healResult { t.Helper() res, err := httpapi.HealChannelRanksOnce(context.Background(), httpapi.Options{Logger: healLogger(logs), Event: env.ev}) if err != nil { t.Fatalf("自愈失败: %v", err) } return res } // healStanding 是 GET /api/standings 里本文件关心的字段。 type healStanding struct { TeamID int64 `json:"teamId"` Gold int `json:"gold"` Silver int `json:"silver"` Bronze int `json:"bronze"` } // medalsByTeam 读当前奖牌榜(只读 channel_ranks 的那张),返回「单位 → 金银铜」。 func medalsByTeam(t *testing.T, env *testEnv) map[int64]healStanding { t.Helper() client, _ := env.client() status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false) if status != http.StatusOK || !resp.OK { t.Fatalf("读奖牌榜应 200,得到 %d %+v", status, resp.Error) } out := map[int64]healStanding{} for _, row := range decodeData[[]healStanding](t, resp) { out[row.TeamID] = row } return out } func channelRankCount(t *testing.T, env *testEnv) int { t.Helper() var n int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM channel_ranks`).Scan(&n); err != nil { t.Fatalf("统计 channel_ranks: %v", err) } return n } func activeLedgerCount(t *testing.T, env *testEnv) int { t.Helper() var n int if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&n); err != nil { t.Fatalf("统计流水: %v", err) } return n } // healSeedAndPublish 造出「老库」的原料:两个单位各一名运动员跑 100 米,发布后 A 金 B 银。 // 不复用 rules_handlers_test.go 的 publishTwoResults,避免测试之间互相牵制。 func healSeedAndPublish(t *testing.T, env *testEnv, ids seedIDs) { t.Helper() env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "requestId": "heal-draft-1", "results": []map[string]any{ {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, }, }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) } status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "requestId": "heal-publish-1", }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error) } } // simulateOldDatabase 把库打回迁移 0020 刚跑完的样子:已发布成绩与 results.rank 都在, // 但 channel_ranks 是空的。此时奖牌榜必然全 0,正是升级当天会出的那个故障。 func simulateOldDatabase(t *testing.T, env *testEnv) { t.Helper() if n := channelRankCount(t, env); n == 0 { t.Fatalf("发布后 channel_ranks 应非空,否则这条用例没在模拟老库") } env.exec(t, `DELETE FROM channel_ranks`) } func TestHealChannelRanksRebuildsMedals(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) healSeedAndPublish(t, env, ids) // 发布后的正常形态:A 金、B 银。 before := medalsByTeam(t, env) if before[ids.teamA].Gold != 1 || before[ids.teamB].Silver != 1 { t.Fatalf("发布后应 A 金 B 银,得到 %+v", before) } simulateOldDatabase(t, env) // 先确认故障确实复现:只读 channel_ranks 的奖牌榜整块归零。 if broken := medalsByTeam(t, env); broken[ids.teamA].Gold != 0 || broken[ids.teamB].Silver != 0 { t.Fatalf("channel_ranks 清空后奖牌榜应为 0(故障复现),得到 %+v", broken) } var logs bytes.Buffer res := runHeal(t, env, &logs) if !res.Triggered { t.Fatalf("老库形态应触发自愈,得到 %+v", res) } if res.EventCount != 1 { t.Fatalf("影响的小项数应为 1,得到 %d", res.EventCount) } if res.LedgerRows <= 0 { t.Fatalf("重算应写回流水,得到 %d", res.LedgerRows) } if n := channelRankCount(t, env); n == 0 { t.Fatalf("自愈后 channel_ranks 应非空") } after := medalsByTeam(t, env) if after[ids.teamA].Gold != 1 || after[ids.teamB].Silver != 1 { t.Fatalf("自愈后奖牌榜应恢复 A 金 B 银,得到 %+v", after) } logText := logs.String() if !strings.Contains(logText, "检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算") { t.Fatalf("触发时应有一条中文 INFO,实际日志:\n%s", logText) } if !strings.Contains(logText, "一次性全量重算完成") { t.Fatalf("完成时应有一条中文 INFO,实际日志:\n%s", logText) } } // channel_ranks 非空 = 名次已经建过,哪怕是老库也不该再动:不能触发重算。 func TestHealChannelRanksSkipsWhenRanksPresent(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) healSeedAndPublish(t, env, ids) before := channelRankCount(t, env) beforeLedger := activeLedgerCount(t, env) var logs bytes.Buffer res := runHeal(t, env, &logs) if res.Triggered { t.Fatalf("channel_ranks 非空不应触发,得到 %+v", res) } if n := channelRankCount(t, env); n != before { t.Fatalf("不应改动 channel_ranks:before=%d after=%d", before, n) } if n := activeLedgerCount(t, env); n != beforeLedger { t.Fatalf("不应改动流水:before=%d after=%d", beforeLedger, n) } if strings.Contains(logs.String(), "一次性全量重算") { t.Fatalf("未触发时不应有重算日志,实际日志:\n%s", logs.String()) } } // 没有「已发布且名次为正」的成绩时(全新库、只有草稿的库)都不该触发。 func TestHealChannelRanksSkipsWithoutPublishedRanks(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) // 全新库:channel_ranks 空,但没有已发布成绩。 var logs bytes.Buffer if res := runHeal(t, env, &logs); res.Triggered { t.Fatalf("全新库不应触发,得到 %+v", res) } // 只有草稿、且草稿连名次都填了:state=draft 仍不算「已发布」。 env.exec(t, `INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank) VALUES (?, ?, 11.58, '11.58', 's', 'ok', 'draft', 1)`, ids.event, ids.entryA) logs.Reset() if res := runHeal(t, env, &logs); res.Triggered { t.Fatalf("只有草稿不应触发,得到 %+v", res) } if logs.Len() != 0 { t.Fatalf("未触发时不应有日志,实际:\n%s", logs.String()) } } // 两次启动(两次调用)结果一致:第一次补上名次,第二次因表非空而跳过,数据不翻倍。 func TestHealChannelRanksIsIdempotent(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) healSeedAndPublish(t, env, ids) simulateOldDatabase(t, env) var firstLogs bytes.Buffer first := runHeal(t, env, &firstLogs) if !first.Triggered { t.Fatalf("第一次应触发,得到 %+v", first) } ranksAfterFirst := channelRankCount(t, env) ledgerAfterFirst := activeLedgerCount(t, env) medalsAfterFirst := medalsByTeam(t, env) var secondLogs bytes.Buffer second := runHeal(t, env, &secondLogs) if second.Triggered { t.Fatalf("第二次不应再触发,得到 %+v", second) } if n := channelRankCount(t, env); n != ranksAfterFirst { t.Fatalf("两次调用后 channel_ranks 应一致:%d → %d", ranksAfterFirst, n) } if n := activeLedgerCount(t, env); n != ledgerAfterFirst { t.Fatalf("两次调用后流水应一致(不翻倍):%d → %d", ledgerAfterFirst, n) } if after := medalsByTeam(t, env); after[ids.teamA] != medalsAfterFirst[ids.teamA] || after[ids.teamB] != medalsAfterFirst[ids.teamB] { t.Fatalf("两次调用后奖牌榜应一致:%+v → %+v", medalsAfterFirst, after) } // 再换一个 logger 调一次(模拟重启后新建 logger 的进程),结论必须一样。 discard := slog.New(slog.NewTextHandler(io.Discard, nil)) if res, err := httpapi.HealChannelRanksOnce(context.Background(), httpapi.Options{Logger: discard, Event: env.ev}); err != nil || res.Triggered { t.Fatalf("第三次调用应跳过:res=%+v err=%v", res, err) } }