package httpapi_test import ( "net/http" "testing" "time" ) // batchData 解一次批量动作的响应体。 type batchItemView struct { ID int64 `json:"id"` OK bool `json:"ok"` Code string `json:"code"` Message string `json:"message"` } type batchResultView struct { Action string `json:"action"` DryRun bool `json:"dryRun"` Requested int `json:"requested"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` Items []batchItemView `json:"items"` Purge *struct { DeletedEntries int `json:"deletedEntries"` DeletedResults int `json:"deletedResults"` DeletedLedger int `json:"deletedLedger"` DeletedBreaks int `json:"deletedBreaks"` RecomputedEvent []int64 `json:"recomputedEvents"` } `json:"purge"` } func doBatch(t *testing.T, env *testEnv, client *http.Client, body map[string]any) batchResultView { t.Helper() status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", body, true) if status != http.StatusOK || !env2.OK { t.Fatalf("批量动作应 200,得到 %d %+v", status, env2.Error) } return decodeData[batchResultView](t, env2) } // auditCount 数某个 action 的审计条数;审计在响应之后落库,给一点重试窗口。 func auditCount(t *testing.T, env *testEnv, action string) int { t.Helper() for i := 0; i < 100; i++ { if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action); n > 0 { return n } time.Sleep(10 * time.Millisecond) } return 0 } // 一次请求停用 5 人:库内 5 行 active = 0,响应逐条成功,且只产生一条审计。 func TestBatchDisableAthletesOneRequest(t *testing.T) { env, ids, client := recorderWithSeed(t) targets := []int64{ids.athleteA, ids.athB} for _, name := range []string{"赵一", "钱二", "孙三"} { targets = append(targets, env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, name, ids.teamA)) } result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets}) if result.Action != "disable" || result.Requested != 5 || result.Succeeded != 5 || result.Failed != 0 { t.Fatalf("响应应为 5 成功 0 失败,得到 %+v", result) } for _, item := range result.Items { if !item.OK { t.Fatalf("每条都该成功,得到 %+v", item) } } if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 5 { t.Fatalf("应有 5 人处于停用,得到 %d", n) } // 停用是软删除:报名与成绩一条不动。 if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 { t.Fatalf("停用不该动报名,得到 %d 条", n) } if n := auditCount(t, env, "athlete.batch.disable"); n != 1 { t.Fatalf("整批只应写 1 条审计,得到 %d 条", n) } } // 混入不存在的 id:逐条回报,整批仍然成功,重复 id 只算一次。 func TestBatchDisableReportsMissingIDs(t *testing.T) { env, ids, client := recorderWithSeed(t) result := doBatch(t, env, client, map[string]any{ "action": "disable", "ids": []int64{ids.athleteA, ids.athleteA, 999999}, }) if result.Requested != 2 || result.Succeeded != 1 || result.Failed != 1 { t.Fatalf("重复 id 应去重、缺失 id 应逐条失败,得到 %+v", result) } if len(result.Items) != 2 { t.Fatalf("应逐条回报 2 条,得到 %+v", result.Items) } missing := result.Items[1] if missing.ID != 999999 || missing.OK || missing.Code != "NOT_FOUND" || missing.Message != "运动员不存在" { t.Fatalf("缺失 id 应回报 运动员不存在,得到 %+v", missing) } if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 { t.Fatalf("存在的那个人应被停用,得到 %d", n) } } // 批量彻底删除:删 2 人、重算一次受影响组别,第三个人升到第一并拿到 10 分; // 缺失的 id 不影响其余两人被删掉。 func TestBatchPurgeRecomputesAndReportsPerItem(t *testing.T) { env, ids, client := recorderWithSeed(t) athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA) entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athC) publishResults(t, env, client, ids.event, "batch-purge", 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"}, ) // 前置:A 第一 10 分,C 第三 6 分。 if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) { t.Fatalf("前置条件:C 应是 6 分,得到 %v", got) } result := doBatch(t, env, client, map[string]any{ "action": "purge", "ids": []int64{ids.athleteA, ids.athB, 999999}, }) if result.Succeeded != 2 || result.Failed != 1 { t.Fatalf("应 2 成功 1 失败,得到 %+v", result) } if result.Purge == nil { t.Fatalf("purge 应带回删除规模,得到 %+v", result) } if result.Purge.DeletedEntries != 2 || result.Purge.DeletedResults != 2 || result.Purge.DeletedLedger != 2 { t.Fatalf("删除规模应为 报名2 / 成绩2 / 流水2,得到 %+v", result.Purge) } if len(result.Purge.RecomputedEvent) != 1 || result.Purge.RecomputedEvent[0] != ids.event { t.Fatalf("应重算 1 个受影响小项,得到 %+v", result.Purge.RecomputedEvent) } // 被删的两人痕迹全无,第三人原样保留。 for _, id := range []int64{ids.athleteA, ids.athB} { if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 { t.Fatalf("运动员 %d 应被删掉,得到 %d", id, n) } } if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, athC); n != 1 { t.Fatalf("第三个人的报名不该被删,得到 %d", n) } // 别人的名次被重算:C 从第三升到第一,得分 6 → 10,单位总分跟着变。 if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{10}) { t.Fatalf("C 应随新名次重算成 10 分,得到 %v", got) } if got := teamPoints(t, env, client, ids.teamB); got != 0 { t.Fatalf("B 单位删空后总分应归零,得到 %v", got) } // 榜单也重算了:C 所在的 A 单位仍是 10 分(A 的 10 分换成了 C 的 10 分)。 if got := teamPoints(t, env, client, ids.teamA); got != 10 { t.Fatalf("A 单位应保持 10 分,得到 %v", got) } if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 { t.Fatalf("重算应把旧流水作废留下来") } assertNoForeignKeyViolation(t, env) if n := auditCount(t, env, "athlete.batch.purge"); n != 1 { t.Fatalf("整批只应写 1 条审计,得到 %d 条", n) } } // dryRun 只统计不改库:预览数字与真删同源,但一行都不能动,也不该留审计。 func TestBatchPurgeDryRunChangesNothing(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "batch-dry", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, ) before := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) + tableDump(t, env, `SELECT * FROM entries ORDER BY id`) + tableDump(t, env, `SELECT * FROM results ORDER BY id`) + tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`) result := doBatch(t, env, client, map[string]any{ "action": "purge", "ids": []int64{ids.athleteA}, "dryRun": true, }) if !result.DryRun || result.Succeeded != 1 { t.Fatalf("预览应逐条成功并标记 dryRun,得到 %+v", result) } if result.Purge == nil || result.Purge.DeletedEntries != 1 || result.Purge.DeletedResults != 1 { t.Fatalf("预览应报出会删掉什么,得到 %+v", result.Purge) } if len(result.Purge.RecomputedEvent) != 1 { t.Fatalf("预览应报出会重算哪个小项,得到 %+v", result.Purge.RecomputedEvent) } after := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) + tableDump(t, env, `SELECT * FROM entries ORDER BY id`) + tableDump(t, env, `SELECT * FROM results ORDER BY id`) + tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`) if before != after { t.Fatalf("预览不该改动任何数据:\n%s\n---\n%s", before, after) } if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'athlete.batch%'`); n != 0 { t.Fatalf("预览不该写审计,得到 %d 条", n) } } // 非法入参:action 只认 disable / purge,ids 去重后不能为空。 func TestBatchRejectsBadInput(t *testing.T) { env, ids, client := recorderWithSeed(t) for _, tc := range []struct { name string body map[string]any want string }{ {"action 非法", map[string]any{"action": "delete", "ids": []int64{ids.athleteA}}, "action 只能是 disable / purge"}, {"ids 为空", map[string]any{"action": "disable", "ids": []int64{}}, "ids 不能为空"}, {"ids 全非法", map[string]any{"action": "purge", "ids": []int64{0, -3}}, "ids 不能为空"}, } { status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", tc.body, true) if status != http.StatusBadRequest { t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error) } if env2.Error == nil || env2.Error.Message != tc.want { t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error) } } } // 未登录的写操作一律 401,且不改动任何数据。 func TestBatchRequiresLogin(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) client, _ := env.client() status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", map[string]any{"action": "disable", "ids": []int64{ids.athleteA}}, false) if status != http.StatusUnauthorized { t.Fatalf("未登录应 401,得到 %d %+v", status, env2.Error) } if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 0 { t.Fatalf("被拒后不该改动数据,得到 %d", n) } }