用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
412 lines
18 KiB
Go
412 lines
18 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// batchItem / batchSummary / batchResult 是 POST /api/entries/batch 的响应契约,
|
||
// 用例直接按前端会收到的 JSON 字段来断言,避免服务端字段改名后测试还绿着。
|
||
type batchItem struct {
|
||
ID int64 `json:"id"`
|
||
OK bool `json:"ok"`
|
||
Error string `json:"error"`
|
||
Skipped bool `json:"skipped"`
|
||
Note string `json:"note"`
|
||
}
|
||
|
||
type batchSummary struct {
|
||
OK int `json:"ok"`
|
||
Failed int `json:"failed"`
|
||
Skipped int `json:"skipped"`
|
||
Changed int `json:"changed"`
|
||
RemovedEntries int `json:"removedEntries"`
|
||
RemovedResults int `json:"removedResults"`
|
||
RemovedLedger int `json:"removedLedger"`
|
||
RemovedBreaks int `json:"removedBreaks"`
|
||
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||
}
|
||
|
||
type batchResult struct {
|
||
Items []batchItem `json:"items"`
|
||
Summary batchSummary `json:"summary"`
|
||
Raw map[string]any `json:"-"`
|
||
}
|
||
|
||
// entryStatus 读报名项当前状态。
|
||
func entryStatus(t *testing.T, env *testEnv, entryID int64) string {
|
||
t.Helper()
|
||
var status string
|
||
if err := env.ev.QueryRow(`SELECT status FROM entries WHERE id = ?`, entryID).Scan(&status); err != nil {
|
||
t.Fatalf("读报名项状态: %v", err)
|
||
}
|
||
return status
|
||
}
|
||
|
||
// addTeamAthleteEntry 在既有小项 / 组别下补一个单位 + 运动员 + 报名项。
|
||
func addTeamAthleteEntry(t *testing.T, env *testEnv, ids seedIDs, code, name string, athleteName string) (int64, int64) {
|
||
t.Helper()
|
||
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, 9, 1)`,
|
||
code, name, name)
|
||
athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
|
||
athleteName, teamID)
|
||
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID)
|
||
return teamID, entryID
|
||
}
|
||
|
||
// 批量退赛后别人被重算、被退赛的人不再占名次;恢复后榜单回到原状。
|
||
func TestEntryBatchScratchAndRestore(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
|
||
publishResults(t, env, client, ids.event, "batch",
|
||
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"},
|
||
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||
)
|
||
|
||
// 前置:10 / 8 / 6 分。
|
||
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
|
||
t.Fatalf("前置条件:B 应是 8 分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, teamC); got != 6 {
|
||
t.Fatalf("前置条件:C 单位总分应是 6,得到 %v", got)
|
||
}
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryB, entryC}, "reason": "临时请假",
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[batchResult](t, env2)
|
||
if len(result.Items) != 2 || !result.Items[0].OK || !result.Items[1].OK {
|
||
t.Fatalf("应有 2 条逐条成功,得到 %+v", result.Items)
|
||
}
|
||
if result.Summary.OK != 2 || result.Summary.Failed != 0 || result.Summary.Changed != 2 {
|
||
t.Fatalf("汇总应是 2 成功 / 0 失败,得到 %+v", result.Summary)
|
||
}
|
||
|
||
if got := entryStatus(t, env, ids.entryB); got != "scratched" {
|
||
t.Fatalf("B 应已退赛,得到 %s", got)
|
||
}
|
||
if got := entryStatus(t, env, entryC); got != "scratched" {
|
||
t.Fatalf("C 应已退赛,得到 %s", got)
|
||
}
|
||
// 退赛的行不再有有效流水,旧流水保留为「已作废」的证据。
|
||
if got := activeLedgerPoints(t, env, ids.entryB); len(got) != 0 {
|
||
t.Fatalf("B 退赛后不应再有有效流水,得到 %v", got)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryB); n != 1 {
|
||
t.Fatalf("B 的旧流水应被作废,得到 %d 条", n)
|
||
}
|
||
// 别人被重算:A 仍是第一 10 分,C 退赛不占名次。
|
||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||
t.Fatalf("A 应保持 10 分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, teamC); got != 0 {
|
||
t.Fatalf("C 单位总分应归零,得到 %v", got)
|
||
}
|
||
// 退赛不占名次:已发布成绩的 rank 必须清掉,否则奖牌统计还会给他记一块金牌。
|
||
for _, id := range []int64{ids.entryB, entryC} {
|
||
var rank sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil {
|
||
t.Fatalf("读退赛行的名次: %v", err)
|
||
}
|
||
if rank.Valid {
|
||
t.Fatalf("退赛的报名项不应再占名次,entry %d 的 rank=%d", id, rank.Int64)
|
||
}
|
||
}
|
||
if n := countRows(t, env,
|
||
`SELECT COUNT(*) FROM results r JOIN entries e ON e.id = r.entry_id
|
||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND e.status <> 'registered'`); n != 0 {
|
||
t.Fatalf("非正常参赛的已发布成绩不该带名次,得到 %d 条", n)
|
||
}
|
||
|
||
// 恢复:同一端点 restore,只有 scratched 会被改回 registered。
|
||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "restore", "ids": []int64{ids.entryB, entryC},
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryB); got != "registered" {
|
||
t.Fatalf("B 应恢复为 registered,得到 %s", got)
|
||
}
|
||
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
|
||
t.Fatalf("B 恢复后应重算回 8 分,得到 %v", got)
|
||
}
|
||
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
|
||
t.Fatalf("C 恢复后应重算回 6 分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamB); got != 8 {
|
||
t.Fatalf("B 单位总分应恢复为 8,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, teamC); got != 6 {
|
||
t.Fatalf("C 单位总分应恢复为 6,得到 %v", got)
|
||
}
|
||
// 恢复后名次写回:B 回到第 2 名(A 第一、C 第三)。
|
||
var restoredRank sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&restoredRank); err != nil {
|
||
t.Fatalf("读恢复后的名次: %v", err)
|
||
}
|
||
if !restoredRank.Valid || restoredRank.Int64 != 2 {
|
||
t.Fatalf("B 恢复后应重算回第 2 名,得到 %+v", restoredRank)
|
||
}
|
||
assertNoForeignKeyViolation(t, env)
|
||
}
|
||
|
||
// 已是 dq 的行不能被退赛覆盖;不存在的 id 逐条报错;整批不失败。
|
||
func TestEntryBatchScratchSkipsDQAndReportsMissing(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE entries SET status = 'dq' WHERE id = ?`, ids.entryB)
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryA, ids.entryB, int64(999999)},
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("混选批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[batchResult](t, env2)
|
||
if len(result.Items) != 3 {
|
||
t.Fatalf("3 个 id 应有 3 条逐条结果,得到 %+v", result.Items)
|
||
}
|
||
if !result.Items[0].OK || result.Items[0].Skipped {
|
||
t.Fatalf("A 应正常退赛,得到 %+v", result.Items[0])
|
||
}
|
||
if !result.Items[1].OK || !result.Items[1].Skipped || !strings.Contains(result.Items[1].Note, "dq") {
|
||
t.Fatalf("B 应被跳过并说明是 dq,得到 %+v", result.Items[1])
|
||
}
|
||
if result.Items[2].OK || result.Items[2].Error != "报名项不存在" {
|
||
t.Fatalf("不存在的 id 应逐条报错,得到 %+v", result.Items[2])
|
||
}
|
||
if result.Summary.OK != 2 || result.Summary.Failed != 1 || result.Summary.Skipped != 1 || result.Summary.Changed != 1 {
|
||
t.Fatalf("汇总应是 2 成功(含 1 跳过)/ 1 失败,得到 %+v", result.Summary)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||
t.Fatalf("A 应已退赛,得到 %s", got)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryB); got != "dq" {
|
||
t.Fatalf("B 的 dq 判定不应被覆盖,得到 %s", got)
|
||
}
|
||
assertNoForeignKeyViolation(t, env)
|
||
}
|
||
|
||
// 移出报名:报名、成绩、流水全部按 entry_id 清干净(不留 entry_id 置空的空壳流水),
|
||
// 留下的人按新名次重算。
|
||
func TestEntryBatchRemovePurgesLedgerByEntryID(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
|
||
publishResults(t, env, client, ids.event, "remove",
|
||
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"},
|
||
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
|
||
)
|
||
// 破纪录登记也要跟着消失,否则会留下一条挂不上报名项的记录。
|
||
env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state)
|
||
VALUES (?, ?, 'meet', 11.92, 5, 'pending')`, ids.event, ids.entryB)
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "remove", "ids": []int64{ids.entryB},
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[batchResult](t, env2)
|
||
if result.Summary.RemovedEntries != 1 || result.Summary.RemovedResults != 1 ||
|
||
result.Summary.RemovedLedger != 1 || result.Summary.RemovedBreaks != 1 {
|
||
t.Fatalf("汇总应带回删除规模,得到 %+v", result.Summary)
|
||
}
|
||
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 0 {
|
||
t.Fatalf("报名项应被物理删除,得到 %d 条", n)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||
t.Fatalf("成绩应被删除,得到 %d 条", n)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||
t.Fatalf("流水应按 entry_id 清干净,得到 %d 条", n)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryB); n != 0 {
|
||
t.Fatalf("破纪录登记应被删除,得到 %d 条", n)
|
||
}
|
||
// 关键证据:删完之后不能留下 entry_id 被 SET NULL 置空的成绩流水。
|
||
if n := countRows(t, env,
|
||
`SELECT COUNT(*) FROM score_ledger WHERE entry_id IS NULL AND result_id IS NULL AND source_type = 'result'`); n != 0 {
|
||
t.Fatalf("不应留下 entry_id 被置空的空壳流水,得到 %d 条", n)
|
||
}
|
||
// 留下的人被重算:C 从第三 6 分升到第二 8 分。
|
||
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{8}) {
|
||
t.Fatalf("C 应随新名次重算成 8 分,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, teamC); got != 8 {
|
||
t.Fatalf("C 单位总分应重算成 8,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||
t.Fatalf("被移出的单位总分应归零,得到 %v", got)
|
||
}
|
||
assertNoForeignKeyViolation(t, env)
|
||
}
|
||
|
||
// 跨小项混选允许,但要按 (小项, 组别) 分组各算各的。
|
||
func TestEntryBatchScratchAcrossEvents(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
event2 := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||
compare, scored, multiplier, status)
|
||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||
VALUES (?, '男子', 1, 0)`, event2)
|
||
var profileID int64
|
||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_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 (?, 'athlete', 'result', ?, 0)`, event2, profileID)
|
||
entry2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||
VALUES (?, ?, ?, ?, 'registered')`, event2, category2, ids.teamA, ids.athleteA)
|
||
publishResults(t, env, client, ids.event, "cross1",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
|
||
publishResults(t, env, client, event2, "cross2",
|
||
map[string]any{"entryId": entry2, "resultValue": 23.10, "resultText": "23.10", "unit": "s", "status": "ok"})
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryA, entry2},
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("跨小项批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[batchResult](t, env2)
|
||
if len(result.Summary.RecomputedEvent) != 2 ||
|
||
result.Summary.RecomputedEvent[0] != ids.event || result.Summary.RecomputedEvent[1] != event2 {
|
||
t.Fatalf("两个小项都应重算,得到 %+v", result.Summary.RecomputedEvent)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||
t.Fatalf("小项一的报名应已退赛,得到 %s", got)
|
||
}
|
||
if got := entryStatus(t, env, entry2); got != "scratched" {
|
||
t.Fatalf("小项二的报名应已退赛,得到 %s", got)
|
||
}
|
||
}
|
||
|
||
// 边界与权限:空 ids、重复 ids、超上限、非法 action、guest 写。
|
||
func TestEntryBatchValidation(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "scratch", "ids": []int64{}}, true)
|
||
if status != http.StatusBadRequest || env2.Error == nil {
|
||
t.Fatalf("空 ids 应 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "nuke", "ids": []int64{ids.entryA}}, true)
|
||
if status != http.StatusBadRequest {
|
||
t.Fatalf("非法 action 应 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
// 重复 id 去重后只回报一条。
|
||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "scratch", "ids": []int64{ids.entryA, ids.entryA}}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("带重复 id 的请求应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
dup := decodeData[batchResult](t, env2)
|
||
if len(dup.Items) != 1 || dup.Summary.Changed != 1 {
|
||
t.Fatalf("重复 id 应去重成一条,得到 %+v", dup)
|
||
}
|
||
|
||
// 超过上限要在动手之前拒绝。
|
||
big := make([]int64, 501)
|
||
for i := range big {
|
||
big[i] = int64(i + 1)
|
||
}
|
||
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "remove", "ids": big}, true)
|
||
if status != http.StatusBadRequest || env2.Error == nil || !strings.Contains(env2.Error.Message, "分批") {
|
||
t.Fatalf("超过上限应 400 并要求分批,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
// guest(未登录)不能写。
|
||
guest, _ := env.client()
|
||
status, env2 = env.do(guest, http.MethodPost, "/api/entries/batch",
|
||
map[string]any{"action": "remove", "ids": []int64{ids.entryA}}, false)
|
||
if status != http.StatusUnauthorized {
|
||
t.Fatalf("未登录批量操作应 401,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 {
|
||
t.Fatalf("guest 不应删掉任何报名项,得到 %d 条", n)
|
||
}
|
||
}
|
||
|
||
// waitBatchAudit 取批量操作的审计 after_json:批量动作没有单个实体 id
|
||
// (entity_id 落库为 NULL),所以按 action 找,和 waitAudit 的单实体口径区分开。
|
||
func waitBatchAudit(t *testing.T, env *testEnv, action string) string {
|
||
t.Helper()
|
||
for i := 0; i < 100; i++ {
|
||
var after sql.NullString
|
||
err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = ? AND entity_id IS NULL`,
|
||
action).Scan(&after)
|
||
switch {
|
||
case err == nil:
|
||
return after.String
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
time.Sleep(10 * time.Millisecond)
|
||
default:
|
||
t.Fatalf("查询审计: %v", err)
|
||
}
|
||
}
|
||
t.Fatalf("等不到 %s 的审计记录", action)
|
||
return ""
|
||
}
|
||
|
||
// 审计要带 ids 与 summary,三个动作各一条。
|
||
func TestEntryBatchAudit(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
for _, action := range []string{"scratch", "restore", "remove"} {
|
||
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": action, "ids": []int64{ids.entryA}, "reason": "实机验证",
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("%s 应 200,得到 %d %+v", action, status, env2.Error)
|
||
}
|
||
after := waitBatchAudit(t, env, "entry."+action)
|
||
for _, want := range []string{fmt.Sprintf(`"ids":[%d]`, ids.entryA), `"summary"`, `"实机验证"`} {
|
||
if !strings.Contains(after, want) {
|
||
t.Fatalf("%s 审计应含 %s,得到 %s", action, want, after)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 保障:remove 之后 results 表里的 rank 不能留下指向已删报名项的悬挂行。
|
||
func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
publishResults(t, env, client, ids.event, "keep",
|
||
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"},
|
||
)
|
||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "remove", "ids": []int64{ids.entryA},
|
||
}, true); status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
var (
|
||
rank sql.NullInt64
|
||
state string
|
||
)
|
||
if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = ?`, ids.entryB).
|
||
Scan(&rank, &state); err != nil {
|
||
t.Fatalf("读 B 的成绩: %v", err)
|
||
}
|
||
if !rank.Valid || rank.Int64 != 1 || state != "published" {
|
||
t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state)
|
||
}
|
||
}
|