package httpapi_test import ( "fmt" "net/http" "strings" "testing" ) // clearResults 走真实接口清空指定范围,返回响应体。 func clearResults(t *testing.T, env *testEnv, client *http.Client, body map[string]any, wantStatus int) map[string]any { t.Helper() status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true) if status != wantStatus { t.Fatalf("清空应 %d,得到 %d %+v", wantStatus, status, env2.Error) } if wantStatus != http.StatusOK { return nil } return decodeData[map[string]any](t, env2) } // validLedgerPoints 数该小项当前有效流水的合计分。 func validLedgerPoints(t *testing.T, env *testEnv, eventID int64) int { t.Helper() return countRows(t, env, `SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`, eventID) } // 清空本组:已发布的分被收回(榜单分数下降)、成绩内容回到「没录过」、写审计与变更历史。 // // 这是主人点名的危险操作入口:撤销发布保留成绩内容,清空连内容一起清掉,两者不能混为一谈。 func TestClearGroupResultsWipesScoresAndContent(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "clear-group", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, ) // 前置:榜上确有名次分(前两名 10 + 8 = 18),且两条都是已发布。 if got := teamPoints(t, env, client, ids.teamA); got != 10 { t.Fatalf("前置:A 队应有 10 分,得到 %v", got) } if got := validLedgerPoints(t, env, ids.event); got != 18 { t.Fatalf("前置:有效流水应合计 18 分,得到 %d", got) } assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event) body := clearResults(t, env, client, map[string]any{ "eventId": ids.event, "categoryId": ids.category, "confirm": true, "reason": "现场重赛", }, http.StatusOK) // 回执要说清清了几条、其中几条原先已发布、作废了几条流水。 if body["scope"] != "group" || body["cleared"] != float64(2) || body["published"] != float64(2) { t.Fatalf("回执不对: %+v", body) } if body["voidedLedger"] != float64(2) { t.Fatalf("应作废 2 条流水(两条名次分各一条),得到 %v", body["voidedLedger"]) } // ① 分数收回:榜单回到 0,有效流水一条不剩,作废行保留(审计痕迹)。 if got := teamPoints(t, env, client, ids.teamA); got != 0 { t.Fatalf("清空后 A 队应 0 分,得到 %v", got) } if got := teamPoints(t, env, client, ids.teamB); got != 0 { t.Fatalf("清空后 B 队应 0 分,得到 %v", got) } assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event) assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 1`, 2, ids.event) // ② 成绩内容清空(回到「没录过」):名次 / 成绩值 / 文本 / 状态 / 备注全清,state 退回 draft。 assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft' AND rank IS NULL AND result_value IS NULL AND result_text IS NULL AND unit IS NULL AND note IS NULL AND status = 'ok'`, 2, ids.event) // 录名次的那一列也要跟着走:清空后不该还留着旧名次。 assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event) // ③ 录入页读到的这一组不再有「已发布」的行(按钮与锁定都据此判断)。 board := entryStates(t, env, client, ids.event) if board[ids.entryA] != "draft" || board[ids.entryB] != "draft" { t.Fatalf("清空后成绩状态应是 draft,得到 %v", board) } // ④ 小项进度跟着退回:左栏不能一边写着「已发布」、一边一条已发布成绩都没有。 var status string if err := env.ev.QueryRow(`SELECT status FROM events WHERE id = ?`, ids.event).Scan(&status); err != nil { t.Fatalf("读小项状态: %v", err) } if status == "published" { t.Fatalf("清空后小项状态不该还是 published(左栏会显示「已发布」却没有已发布成绩)") } // ⑤ 审计:谁在什么时候清空了哪一组,连带条数。 var action, payload string if err := env.ev.QueryRow( `SELECT action, after_json FROM audit_logs WHERE action = 'result.clear'`).Scan(&action, &payload); err != nil { t.Fatalf("应写一条 result.clear 审计: %v", err) } for _, want := range []string{`"categoryId":`, `"scope":"group"`, `"cleared":2`, `"published":2`, `现场重赛`} { if !strings.Contains(payload, want) { t.Fatalf("审计载荷应含 %s,得到 %s", want, payload) } } // ⑥ 变更历史:清空按「撤销发布」记一条,message 里写明清了多少条。 var message string if err := env.ev.QueryRow( `SELECT message FROM result_revisions WHERE event_id = ? AND action = 'unpublish' ORDER BY id DESC LIMIT 1`, ids.event).Scan(&message); err != nil { t.Fatalf("应写一条 unpublish 修订: %v", err) } if !strings.Contains(message, "清空本组成绩:2 条") { t.Fatalf("修订文案应写明清空条数,得到 %q", message) } } // 清空本批:只清当前批次,别的批次的已发布成绩与分数原样保留。 func TestClearBatchKeepsOtherBatches(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 6) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") // 第 1 批:前 3 条;第 2 批:后 3 条。两批都录成绩并发布。 for i, entryID := range entries { heat := int64(1) if i >= 3 { heat = 2 } if status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{ "entryIds": []int64{entryID}, "heat": heat, }, true); status != http.StatusOK { t.Fatalf("设置批次应 200,得到 %d %+v", status, e.Error) } } times := make([]float64, 6) for i := range times { times[i] = 12.0 + float64(i)*0.1 } if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 6, 0, times), }, true); status != http.StatusOK { t.Fatalf("存草稿应 200,得到 %d %+v", status, e.Error) } if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK { t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) } // 前置:6 条已发布,前六名 10+8+6+3+2+1 = 30 分。 assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 6, ids.event) if got := validLedgerPoints(t, env, ids.event); got != 30 { t.Fatalf("前置:有效流水应 30 分,得到 %d", got) } body := clearResults(t, env, client, map[string]any{ "eventId": ids.event, "categoryId": ids.category, "heat": 1, "confirm": true, }, http.StatusOK) if body["scope"] != "batch" || body["cleared"] != float64(3) || body["published"] != float64(3) { t.Fatalf("回执不对: %+v", body) } // 第 1 批清零,第 2 批原样还在。 for i := 0; i < 3; i++ { var state string if err := env.ev.QueryRow(`SELECT state FROM results WHERE entry_id = ?`, entries[i]).Scan(&state); err != nil { t.Fatalf("读第 1 批状态: %v", err) } if state != "draft" { t.Fatalf("第 1 批第 %d 条应退回草稿,得到 %s", i+1, state) } } assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 3, ids.event) // 剩下第 2 批三个人:第 1、2、3 名 → 10 + 8 + 6 = 24 分(名次整体往前挪,与「只发布过这三条」一致)。 if got := validLedgerPoints(t, env, ids.event); got != 24 { t.Fatalf("清掉一批后有效流水应 24 分,得到 %d", got) } // 榜单分数必须真的下降:清空前 30 分,清掉一批后只剩第 2 批那三个人(10+8+6=24)。 if got := teamPoints(t, env, client, ids.teamA) + teamPoints(t, env, client, ids.teamB); got != 24 { t.Fatalf("清批后两单位合计应 24 分,得到 %v", got) } } // 没勾选确认不许提交:服务端也拦(前端禁用确认键只是界面防呆)。 func TestClearRequiresConfirmCheckbox(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "clear-confirm", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, ) for _, body := range []map[string]any{ {"eventId": ids.event, "categoryId": ids.category}, {"eventId": ids.event, "categoryId": ids.category, "confirm": false}, } { status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true) if status != http.StatusBadRequest { t.Fatalf("没勾选确认应 400,得到 %d %+v", status, env2.Error) } if env2.Error == nil || !strings.Contains(env2.Error.Message, "勾选") { t.Fatalf("400 应说清要先勾选,得到 %+v", env2.Error) } } // 一条都不能被动过。 assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event) if got := validLedgerPoints(t, env, ids.event); got != 10 { t.Fatalf("被拦下的请求不该动流水,得到 %d", got) } } // 清空要按「小项 + 组别」为界:缺 categoryId 一律 400(没有「一键清空整项」这条危险路)。 func TestClearRequiresCategory(t *testing.T) { env, ids, client := recorderWithSeed(t) for _, body := range []map[string]any{ {"eventId": ids.event, "confirm": true}, {"eventId": ids.event, "categoryId": 0, "confirm": true}, } { status, env2 := env.do(client, http.MethodPost, "/api/results/clear", body, true) if status != http.StatusBadRequest { t.Fatalf("缺 categoryId 应 400,得到 %d %+v", status, env2.Error) } if env2.Error == nil || !strings.Contains(env2.Error.Message, "categoryId") { t.Fatalf("400 应点名 categoryId,得到 %+v", env2.Error) } } } // 空范围给明确回答,而不是静默「清空 0 条」——操作员多半看错了批次 / 组别。 func TestClearEmptyScopeIsRejected(t *testing.T) { env, ids, client := recorderWithSeed(t) status, env2 := env.do(client, http.MethodPost, "/api/results/clear", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "confirm": true, }, true) if status != http.StatusBadRequest { t.Fatalf("没有成绩时清空应 400,得到 %d %+v", status, env2.Error) } if env2.Error == nil || !strings.Contains(env2.Error.Message, "没有成绩") { t.Fatalf("400 应说清没有成绩可清,得到 %+v", env2.Error) } } // 权限与录成绩同档:访客 403,recorder 放行。 func TestClearNeedsRecorder(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "clear-perm", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, ) guest, _ := env.client() status, _ := env.do(guest, http.MethodPost, "/api/results/clear", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "confirm": true, }, false) if status != http.StatusForbidden && status != http.StatusUnauthorized { t.Fatalf("访客清空应被挡下(401/403),得到 %d", status) } assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event) } // 清空整个组别时,连带清掉「按单位录入的团体名次」:否则那一批分没有成绩行, // 重算会原样再写一遍——现场就变成「清空了榜上却还有分」。 func TestClearGroupAlsoClearsTeamManualRanks(t *testing.T) { env, ids, client := recorderWithSeed(t) // 换成「团体 + 手工名次」通道:团体名次走 channel_manual_ranks。 if _, err := env.ev.Exec( `DELETE FROM event_score_channels WHERE event_id = ?`, ids.event); err != nil { t.Fatalf("清通道: %v", err) } var profileID int64 if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&profileID); err != nil { t.Fatalf("读团体模板: %v", err) } env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) VALUES (?, 'team', 'manual', ?, 0)`, ids.event, profileID) // 录两个单位的名次 → 发布 → 榜上每个单位各得名次分。 if status, e := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category), map[string]any{"scope": "team", "basis": "manual", "ranks": []map[string]any{ {"teamId": ids.teamA, "rank": 1}, {"teamId": ids.teamB, "rank": 2}, }}, true); status != http.StatusOK { t.Fatalf("写团体名次应 200,得到 %d %+v", status, e.Error) } if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK { t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) } // 前置:纯团体名次项目,报名项上没有成绩,分全挂在单位上(15 + 10)。 if got := teamPoints(t, env, client, ids.teamA); got != 15 { t.Fatalf("前置:A 队应 15 分,得到 %v", got) } body := clearResults(t, env, client, map[string]any{ "eventId": ids.event, "categoryId": ids.category, "confirm": true, }, http.StatusOK) if body["teamRanks"] != float64(2) { t.Fatalf("应清掉 2 条团体名次,得到 %v", body["teamRanks"]) } assertCount(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ?`, 0, ids.event) // 榜上的分必须真的归零——这正是「清空」与「撤销发布」现场差别最大的地方。 if got := teamPoints(t, env, client, ids.teamA); got != 0 { t.Fatalf("清空后 A 队应 0 分,得到 %v", got) } if got := teamPoints(t, env, client, ids.teamB); got != 0 { t.Fatalf("清空后 B 队应 0 分,得到 %v", got) } }