package httpapi_test import ( "database/sql" "errors" "fmt" "net/http" "strings" "testing" "time" ) // waitAudit 取一条审计的 after_json。审计中间件在响应写完之后才落库, // 所以这里给一小段重试窗口,避免用例跟中间件抢时间。 func waitAudit(t *testing.T, env *testEnv, action string, entityID int64) 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 = ?`, action, entityID).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 "" } // countRows 统计满足条件的行数。 func countRows(t *testing.T, env *testEnv, query string, args ...any) int { t.Helper() var n int if err := env.ev.QueryRow(query, args...).Scan(&n); err != nil { t.Fatalf("统计行数: %v\n%s", err, query) } return n } // activeLedgerPoints 取某个报名名下的有效分值(按流水 id 排序)。 func activeLedgerPoints(t *testing.T, env *testEnv, entryID int64) []float64 { t.Helper() rows, err := env.ev.Query(`SELECT points FROM score_ledger WHERE entry_id = ? AND voided = 0 ORDER BY id`, entryID) if err != nil { t.Fatalf("读流水: %v", err) } defer func() { _ = rows.Close() }() var out []float64 for rows.Next() { var p float64 if err := rows.Scan(&p); err != nil { t.Fatalf("扫描流水: %v", err) } out = append(out, p) } return out } // equalFloats 比较两组分值(都是整数分,直接按元素比)。 func equalFloats(a, b []float64) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // publishResults 走真实的草稿 + 发布接口把成绩发布出去,积分榜据此算分。 func publishResults(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string, rows ...map[string]any) { t.Helper() if status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": eventID, "requestId": tag + "-draft", "results": rows, }, true); status != http.StatusOK { t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error) } if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true); status != http.StatusOK { t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) } } // teamPoints 取积分榜上某个单位的总分。 func teamPoints(t *testing.T, env *testEnv, client *http.Client, teamID int64) float64 { t.Helper() status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false) if status != http.StatusOK || !env2.OK { t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error) } for _, row := range decodeData[[]map[string]any](t, env2) { if row["teamId"] == float64(teamID) { points, _ := row["totalPoints"].(float64) return points } } t.Fatalf("积分榜上没有单位 %d", teamID) return 0 } // 连报名都没有的人:物理删除成功,事后再查就是 404。 func TestPurgeAthleteWithoutAnyData(t *testing.T) { env, _, client := recorderWithSeed(t) id := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('独行侠', '女', (SELECT id FROM teams WHERE code = 'A'), 1)`) status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", id), nil, true) if status != http.StatusOK || !env2.OK { t.Fatalf("无数据的人员应能删除,得到 %d %+v", status, env2.Error) } if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 { t.Fatalf("人员应被删掉,得到 %d 条", n) } if status, _ := env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", id), nil, false); status != http.StatusNotFound { t.Fatalf("删除后再查应 404,得到 %d", status) } } // 只有报名、没有成绩的人:报名一并删掉。 func TestPurgeAthleteWithEntriesOnly(t *testing.T) { env, ids, client := recorderWithSeed(t) if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 { t.Fatalf("前置条件:athleteA 应有 1 条报名,得到 %d", n) } status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true) if status != http.StatusOK || !env2.OK { t.Fatalf("只有报名的人应能删除,得到 %d %+v", status, env2.Error) } if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 0 { t.Fatalf("报名应一并删除,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, ids.athleteA); n != 0 { t.Fatalf("人员应被删掉,得到 %d 条", n) } } // 有已发布成绩的人:人员、报名、成绩、流水(含已作废的历史流水)全删干净, // 并把受影响的组别重算——同组别的 B 从第二升到第一,得分从 8 变 10,且没有重复计分。 func TestPurgeAthleteRecomputesLedger(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "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"}, ) // 前置:A 第一 10 分、B 第二 8 分,单位总分各 10 / 8。 if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) { t.Fatalf("前置条件:A 应是 10 分,得到 %v", got) } if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) { t.Fatalf("前置条件:B 应是 8 分,得到 %v", got) } if got := teamPoints(t, env, client, ids.teamA); got != 10 { t.Fatalf("前置条件:A 单位总分应是 10,得到 %v", got) } // 破纪录登记与该运动员名下的历史流水(voided = 1)也要跟着消失。 env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state) VALUES (?, ?, 'meet', 11.58, 5, 'pending')`, ids.event, ids.entryA) env.exec(t, `INSERT INTO score_ledger (team_id, event_id, entry_id, athlete_id, source_type, source_id, points, voided) VALUES (?, ?, ?, ?, 'result', ?, 7, 1)`, ids.teamA, ids.event, ids.entryA, ids.athleteA, ids.entryA) var logBefore int64 if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&logBefore); err != nil { t.Fatalf("读变更日志: %v", err) } status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true) if status != http.StatusOK || !env2.OK { t.Fatalf("删除应 200,得到 %d %+v", status, env2.Error) } purged := decodeData[map[string]any](t, env2) if purged["deletedEntries"] != float64(1) || purged["deletedResults"] != float64(1) || purged["deletedLedger"] != float64(2) || purged["deletedBreaks"] != float64(1) { t.Fatalf("响应应带回删除规模,得到 %+v", purged) } // A 的痕迹全没了——包含 voided = 1 的历史流水。 for _, c := range []struct { name string query string }{ {"人员", `SELECT COUNT(*) FROM athletes WHERE id = ?`}, {"报名", `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`}, {"名下的流水", `SELECT COUNT(*) FROM score_ledger WHERE athlete_id = ?`}, {"报名名下的流水", `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`}, } { arg := ids.athleteA if strings.Contains(c.name, "报名名下") { arg = ids.entryA } if n := countRows(t, env, c.query, arg); n != 0 { t.Fatalf("%s 应删干净,得到 %d 条", c.name, n) } } if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA); n != 0 { t.Fatalf("成绩应删干净,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryA); n != 0 { t.Fatalf("破纪录登记应删干净,得到 %d 条", n) } // 重算发生了:B 升到第一得 10 分,旧流水被作废保留痕迹,没有重复计分。 if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{10}) { t.Fatalf("B 应随新名次重算成 10 分,得到 %v", got) } if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 2 { t.Fatalf("B 应留下「1 条作废 + 1 条有效」,得到 %d 条", n) } 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) } // B 的成绩跟着新名次重排:名次列写回 1,分数与发布态不变。 var ( rank sql.NullInt64 value sql.NullFloat64 state string status2 string ) if err := env.ev.QueryRow(`SELECT rank, result_value, state, status FROM results WHERE entry_id = ?`, ids.entryB). Scan(&rank, &value, &state, &status2); err != nil { t.Fatalf("读 B 的成绩: %v", err) } if !rank.Valid || rank.Int64 != 1 { t.Fatalf("B 的名次应重算成 1,得到 %+v", rank) } if !value.Valid || value.Float64 != 11.92 || state != "published" || status2 != "ok" { t.Fatalf("B 的成绩内容不该被改动: value=%+v state=%s status=%s", value, state, status2) } if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 1 { t.Fatalf("B 的报名不该被删,得到 %d 条", n) } // 榜单跟着变:A 单位归零,B 单位拿到 10 分。 if got := teamPoints(t, env, client, ids.teamA); got != 0 { t.Fatalf("删掉 A 后其单位总分应归零,得到 %v", got) } if got := teamPoints(t, env, client, ids.teamB); got != 10 { t.Fatalf("B 的单位总分应重算成 10,得到 %v", got) } // 审计带上删除规模与重算的小项。 after := waitAudit(t, env, "athlete.purge", ids.athleteA) for _, want := range []string{`"deletedEntries":1`, `"deletedResults":1`, fmt.Sprintf(`"recomputedEvents":[%d]`, ids.event)} { if !strings.Contains(after, want) { t.Fatalf("审计应含 %s,得到 %s", want, after) } } // 删干净之后库里不该留下任何悬挂外键。 assertNoForeignKeyViolation(t, env) // 大屏收到通知:受影响小项与榜单的变更记录都在。 pushed := countRows(t, env, `SELECT COUNT(*) FROM change_log WHERE id > ? AND entity_type IN ('events', 'standings')`, logBefore) if pushed < 2 { t.Fatalf("应把受影响小项与榜单推给大屏,得到 %d 条变更记录", pushed) } } // 重算只碰被删成绩所在的组别:另一个组别的流水连 id 都不该变。 func TestPurgeAthleteLeavesOtherCategoryUntouched(t *testing.T) { env, ids, client := recorderWithSeed(t) cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event) entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamB, ids.athB) publishResults(t, env, client, ids.event, "purge-cat", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, map[string]any{"entryId": entryB2, "resultValue": 12.30, "resultText": "12.30", "unit": "s", "status": "ok"}, ) var beforeID, beforeResultID, beforeEntryID int64 var beforePoints float64 if err := env.ev.QueryRow(`SELECT id, result_id, entry_id, points FROM score_ledger WHERE entry_id = ? AND voided = 0`, entryB2). Scan(&beforeID, &beforeResultID, &beforeEntryID, &beforePoints); err != nil { t.Fatalf("读另一个组别的流水: %v", err) } status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true) if status != http.StatusOK { t.Fatalf("删除应 200,得到 %d %+v", status, env2.Error) } purged := decodeData[map[string]any](t, env2) // 只重算了 A 成绩所在的组别。 if events, _ := purged["recomputedEvents"].([]any); len(events) != 1 || events[0] != float64(ids.event) { t.Fatalf("只应重算受影响小项,得到 %+v", purged["recomputedEvents"]) } var afterID, afterResultID, afterEntryID int64 var afterPoints float64 if err := env.ev.QueryRow(`SELECT id, result_id, entry_id, points FROM score_ledger WHERE entry_id = ? AND voided = 0`, entryB2). Scan(&afterID, &afterResultID, &afterEntryID, &afterPoints); err != nil { t.Fatalf("重算后读另一个组别的流水: %v", err) } if afterID != beforeID || afterResultID != beforeResultID || afterEntryID != beforeEntryID || afterPoints != beforePoints { t.Fatalf("另一个组别的流水不该被重算: 之前 id=%d result=%d entry=%d points=%v,之后 id=%d result=%d entry=%d points=%v", beforeID, beforeResultID, beforeEntryID, beforePoints, afterID, afterResultID, afterEntryID, afterPoints) } // 另一个组别的报名与成绩也在。 if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, entryB2); n != 1 { t.Fatalf("另一个组别的报名不该被动,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, entryB2); n != 1 { t.Fatalf("另一个组别的成绩不该被动,得到 %d 条", n) } } // assertNoForeignKeyViolation 断言库里没有悬挂外键——级联删除最容易留下的后患。 func assertNoForeignKeyViolation(t *testing.T, env *testEnv) { t.Helper() rows, err := env.ev.Query(`PRAGMA foreign_key_check`) if err != nil { t.Fatalf("外键检查: %v", err) } defer func() { _ = rows.Close() }() if rows.Next() { var table string if err := rows.Scan(&table, new(any), new(any), new(any)); err != nil { t.Fatalf("读外键检查结果: %v", err) } t.Fatalf("删除后存在外键悬挂,表 %s", table) } } // 人员不存在时(含 purge=1)404 沿用仓库既有语义。 func TestPurgeMissingAthleteReturns404(t *testing.T) { env, _, client := recorderWithSeed(t) status, env2 := env.do(client, http.MethodDelete, "/api/athletes/999999?purge=1", nil, true) if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "运动员不存在" { t.Fatalf("不存在的人员应 404 运动员不存在,得到 %d %+v", status, env2.Error) } } // 不带参数的 DELETE 仍是停用:只置 active = 0,报名、成绩、流水全留着。 func TestSoftDeleteAthleteKeepsData(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "soft", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, ) // purge=0 这类明确否定的写法同样按停用处理。 for _, path := range []string{ fmt.Sprintf("/api/athletes/%d", ids.athleteA), fmt.Sprintf("/api/athletes/%d?purge=0", ids.athleteA), } { if status, env2 := env.do(client, http.MethodDelete, path, nil, true); status != http.StatusOK || !env2.OK { t.Fatalf("DELETE %s 应 200,得到 %d %+v", path, status, env2.Error) } } if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 { t.Fatalf("停用应把 active 置 0,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 { t.Fatalf("停用不该动报名,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA); n != 1 { t.Fatalf("停用不该动成绩,得到 %d 条", n) } if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) { t.Fatalf("停用不该动流水,得到 %v", got) } }