Files
Scoreboard/server/internal/httpapi/repo_test.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

755 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// 单位增删改必须写 change_log,否则 broadcastChanges 推空、大屏单位名不刷新。
func TestTeamWritesWriteChangeLogAndBroadcast(t *testing.T) {
fake := &fakeRealtime{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
fake.db = env.ev
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "C", "name": "第三分公司", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建单位应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "teams", "upsert"); got != 1 {
t.Fatalf("建单位应写 1 条 teams change_log,得到 %d", got)
}
created := decodeData[map[string]any](t, env2)
teamID := int64(created["id"].(float64))
found := false
for _, ch := range fake.firstBatch(t) {
if ch.Entity == "teams" && ch.Action == "upsert" && ch.ID == teamID {
found = true
}
}
if !found {
t.Fatalf("建单位后应广播 teams 变更")
}
if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d", teamID), nil, true); status != http.StatusOK {
t.Fatalf("删单位应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "teams", "void"); got != 1 {
t.Fatalf("删单位应写 1 条 teams void 变更,得到 %d", got)
}
}
// 项目、组别、通道的写操作同样要推 change_log,大屏才会刷新。
func TestEventWritesWriteChangeLog(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "M400", "name": "400米跑",
"eventType": "individual", "resultType": "time", "compare": "asc", "scored": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "upsert"); got != 1 {
t.Fatalf("建项目应写 1 条 events change_log,得到 %d", got)
}
created := decodeData[map[string]any](t, env2)
eventID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories", eventID), map[string]any{
"categories": []map[string]any{{"name": "男子", "multiplier": 1}},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("替换组别应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "upsert"); got != 2 {
t.Fatalf("替换组别应再写 1 条 events change_log,得到 %d", got)
}
if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", eventID), nil, true); status != http.StatusOK {
t.Fatalf("删项目应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "void"); got != 1 {
t.Fatalf("删项目应写 1 条 events void 变更,得到 %d", got)
}
}
// 小项类型新增「个人+团体」:REST 写接口要能存下来并原样返回,不被当成未知类型。
func TestEventAcceptsIndividualTeamType(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "TEAM_PULLUP", "name": "团体引体向上",
"eventType": "individual_team", "resultType": "count", "compare": "desc", "scored": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建「个人+团体」项目应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["eventType"] != "individual_team" {
t.Fatalf("建回的 eventType = %v,期望 individual_team", created["eventType"])
}
eventID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d", eventID), nil, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读项目应 200,得到 %d %+v", status, env2.Error)
}
if got := decodeData[map[string]any](t, env2); got["eventType"] != "individual_team" {
t.Fatalf("读回的 eventType = %v,期望 individual_team", got["eventType"])
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), map[string]any{
"sportId": ids.sport, "code": "TEAM_PULLUP", "name": "团体引体向上",
"eventType": "team", "resultType": "count", "compare": "desc", "scored": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改回团体应 200,得到 %d %+v", status, env2.Error)
}
if got := decodeData[map[string]any](t, env2); got["eventType"] != "team" {
t.Fatalf("改回的 eventType = %v,期望 team", got["eventType"])
}
}
// 证件号是人员的唯一标识:可读写、重复 409、空值不互斥(部分唯一索引)。
func TestAthleteIDCardUnique(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "idCard": "110101199001011234", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["idCard"] != "110101199001011234" {
t.Fatalf("建人员应返回 idCard: %+v", created)
}
athleteID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "李四", "gender": "男", "teamId": ids.teamA, "idCard": "110101199001011234", "active": true,
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("重复证件号应 409 CONFLICT,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athB), map[string]any{
"name": "李强", "gender": "男", "teamId": ids.teamB, "idCard": "110101199001011234", "active": true,
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("改人员到重复证件号应 409,得到 %d %+v", status, env2.Error)
}
// 空证件号是合法的(历史数据),多条空值不能互相冲突。
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "王五", "gender": "女", "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusOK {
t.Fatalf("空证件号应可建,得到 %d %+v", status, env2.Error)
}
env.exec(t, `INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES ('甲', '男', ?, '', 1)`, ids.teamA)
env.exec(t, `INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES ('乙', '男', ?, '', 1)`, ids.teamA)
if _, err := env.ev.Exec(
`INSERT INTO athletes (name, team_id, id_card, active) VALUES ('丙', ?, '110101199001011234', 1)`, ids.teamA); err == nil {
t.Fatalf("唯一索引应拒绝重复证件号")
}
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false)
got := decodeData[map[string]any](t, env2)
if status != http.StatusOK || got["idCard"] != "110101199001011234" {
t.Fatalf("读人员应带 idCard,得到 %d %+v", status, got)
}
}
// 号码布与证件号同为唯一标识:可读写、重复 409(文案区分字段)、空值不互斥。
func TestAthleteBibUnique(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "bib": "A001", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("仅给号码布应能建人员,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["bib"] != "A001" {
t.Fatalf("建人员应返回 bib: %+v", created)
}
athleteID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "李四", "gender": "男", "teamId": ids.teamA, "bib": "A001", "active": true,
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("重复号码布应 409 CONFLICT,得到 %d %+v", status, env2.Error)
}
if !strings.Contains(env2.Error.Message, "号码布") {
t.Fatalf("409 文案应点明号码布,得到 %+v", env2.Error)
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athB), map[string]any{
"name": "李强", "gender": "男", "teamId": ids.teamB, "bib": "A001", "active": true,
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("改人员到重复号码布应 409,得到 %d %+v", status, env2.Error)
}
// 空号码布是合法的,多条空值不能互相冲突;唯一索引兜底重复写入。
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "王五", "gender": "女", "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusOK {
t.Fatalf("空号码布应可建,得到 %d %+v", status, env2.Error)
}
env.exec(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('甲', '男', ?, '', 1)`, ids.teamA)
env.exec(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('乙', '男', ?, '', 1)`, ids.teamA)
if _, err := env.ev.Exec(
`INSERT INTO athletes (name, team_id, bib, active) VALUES ('丙', ?, 'A001', 1)`, ids.teamA); err == nil {
t.Fatalf("唯一索引应拒绝重复号码布")
}
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false)
got := decodeData[map[string]any](t, env2)
if status != http.StatusOK || got["bib"] != "A001" {
t.Fatalf("读人员应带 bib,得到 %d %+v", status, got)
}
}
// 年龄可读可写;越界或非整数返回 400,不填时为空。
func TestAthleteAgeReadWriteAndRange(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 30, "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["age"] != float64(30) {
t.Fatalf("建人员应返回 age: %+v", created)
}
athleteID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false)
if got := decodeData[map[string]any](t, env2); status != http.StatusOK || got["age"] != float64(30) {
t.Fatalf("读人员应带 age,得到 %d %+v", status, got)
}
// 边界值 0 与 120 合法。
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 0, "active": true,
}, true)
if status != http.StatusOK || decodeData[map[string]any](t, env2)["age"] != float64(0) {
t.Fatalf("age=0 应可保存,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 120, "active": true,
}, true)
if status != http.StatusOK || decodeData[map[string]any](t, env2)["age"] != float64(120) {
t.Fatalf("age=120 应可保存,得到 %d %+v", status, env2.Error)
}
for _, age := range []int{-1, 121} {
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": age, "active": true,
}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("age=%d 应 400,得到 %d %+v", age, status, env2.Error)
}
}
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "李四", "gender": "男", "teamId": ids.teamA, "age": 121, "active": true,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("新建年龄越界应 400,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "赵六", "gender": "男", "teamId": ids.teamA, "age": "abc", "active": true,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("年龄非整数应 400,得到 %d %+v", status, env2.Error)
}
// 不填年龄是合法的,返回空值。
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "王五", "gender": "女", "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusOK {
t.Fatalf("不填年龄应可建,得到 %d %+v", status, env2.Error)
}
if created = decodeData[map[string]any](t, env2); created["age"] != nil {
t.Fatalf("没填年龄应返回 null: %+v", created)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, athleteID).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 120 {
t.Fatalf("越界的 400 不该改库中年龄,得到 %+v", age)
}
}
// 运动员性别与组别同口径:非法值在接口层就 400(带字段名与非法值),
// 不再原样落库撞 athletes.gender 的 CHECK 报 500 + 英文约束原文。
func TestAthleteGenderInvalidIsBadRequest(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
for _, gender := range []string{"M", "male", "未知"} {
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": gender, "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("新建 gender=%q 应 400 VALIDATION_FAILED,得到 %d %+v", gender, status, env2.Error)
}
if !strings.Contains(env2.Error.Message, "gender") || !strings.Contains(env2.Error.Message, "男") {
t.Fatalf("400 文案应点明字段与合法取值,得到 %q", env2.Error.Message)
}
if !strings.Contains(string(env2.Error.Detail), gender) {
t.Fatalf("400 的 detail 应带上非法值 %q,得到 %s", gender, env2.Error.Detail)
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athleteA), map[string]any{
"name": "张伟", "gender": gender, "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("改 gender=%q 应 400 VALIDATION_FAILED,得到 %d %+v", gender, status, env2.Error)
}
}
// 被拒的两次都没改库:性别仍是「男」。
var gender string
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
t.Fatalf("读性别: %v", err)
}
if gender != "男" {
t.Fatalf("400 不该改库中性别,得到 %q", gender)
}
// 留空(历史档案里就有空性别)与「男 / 女」照常可写。
for _, g := range []any{nil, "女"} {
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athleteA), map[string]any{
"name": "张伟", "gender": g, "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("gender=%v 应可保存,得到 %d %+v", g, status, env2.Error)
}
}
}
// 积分模板的「录取名次」(scoring_profiles.award_ranks)在写入侧没有校验:
// -1 一路落库撞建表 CHECK(migrations/event/0003:CHECK (award_ranks >= 0)),
// 报出来是 500 + 原始约束文本,而同表的 tieMode / recordBonusMode 早就是 400
// (见 enums_test.go 的 TestProfileEnumValidation)。这里钉住新建与更新两条路径。
func TestProfileAwardRanksValidation(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-BAD", "name": "非法录取名次", "awardRanks": -1,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("awardRanks=-1 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "awardRanks") {
t.Fatalf("400 文案应点明字段名,得到 %q", resp.Error.Message)
}
if !strings.Contains(string(resp.Error.Detail), "-1") {
t.Fatalf("400 的 detail 应带上非法值 -1,得到 %s", resp.Error.Detail)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-BAD'`); n != 0 {
t.Fatalf("被拒的模板不该落库,得到 %d 行", n)
}
// 合法值:0 表示不按名次封顶(domain.basePoints 只在 award_ranks > 0 时才截断),正整数照常。
for _, want := range []int{0, 8} {
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": fmt.Sprintf("P-OK-%d", want), "name": "合法录取名次", "awardRanks": want,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("awardRanks=%d 应 200,得到 %d %+v", want, status, resp.Error)
}
if got := decodeData[map[string]any](t, resp)["awardRanks"]; got != float64(want) {
t.Fatalf("awardRanks=%d 应原样落库,得到 %v", want, got)
}
}
// 更新路径同口径。
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-UPD", "name": "待改模板", "awardRanks": 3,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建待改模板应 200,得到 %d %+v", status, resp.Error)
}
profileID := int64(decodeData[map[string]any](t, resp)["id"].(float64))
path := fmt.Sprintf("/api/scoring-profiles/%d", profileID)
status, resp = env.do(client, http.MethodPut, path, map[string]any{
"code": "P-UPD", "name": "待改模板", "awardRanks": -5,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新 awardRanks=-5 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "awardRanks") {
t.Fatalf("更新的 400 文案也应点明字段名,得到 %q", resp.Error.Message)
}
var stored int
if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = ?`, profileID).Scan(&stored); err != nil {
t.Fatalf("读录取名次: %v", err)
}
if stored != 3 {
t.Fatalf("被拒的更新不该改库,得到 %d", stored)
}
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
"code": "P-UPD", "name": "待改模板", "awardRanks": 4,
}, true); status != http.StatusOK || !resp.OK {
t.Fatalf("更新 awardRanks=4 应 200,得到 %d %+v", status, resp.Error)
}
if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = ?`, profileID).Scan(&stored); err != nil {
t.Fatalf("读录取名次: %v", err)
}
if stored != 4 {
t.Fatalf("合法更新应落库 4,得到 %d", stored)
}
}
// last_place_zero 没有补写入侧校验,因为它取不到 CHECK (last_place_zero IN (0, 1)) 之外的值:
// profileDTO.LastPlaceZero 是 Go 的 bool,落库的 0/1 由类型直接决定,传数字会被 JSON 解码
// 拦成 400(而不是 500)。这条用例把「本来就有的保证」钉住,以后谁把字段改成 int 就会变红。
func TestProfileLastPlaceZeroIsBooleanTyped(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "P-LPZ-BAD", "name": "非布尔", "lastPlaceZero": 2,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("lastPlaceZero=2 应 400 VALIDATION_FAILED(类型不合法),得到 %d %+v", status, resp.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-LPZ-BAD'`); n != 0 {
t.Fatalf("类型不合法的模板不该落库,得到 %d 行", n)
}
for _, tc := range []struct {
code string
set bool
want int
}{
{"P-LPZ-TRUE", true, 1},
{"P-LPZ-FALSE", false, 0},
} {
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": tc.code, "name": "布尔模板", "lastPlaceZero": tc.set,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("%s lastPlaceZero=%v 应 200,得到 %d %+v", tc.code, tc.set, status, resp.Error)
}
var stored int
if err := env.ev.QueryRow(`SELECT last_place_zero FROM scoring_profiles WHERE code = ?`, tc.code).Scan(&stored); err != nil {
t.Fatalf("读最后名次计分: %v", err)
}
if stored != tc.want {
t.Fatalf("%s last_place_zero 应为 %d,得到 %d", tc.code, tc.want, stored)
}
}
}
// assertCodeNameRequired 钉住「必填」的三态:传空串、传纯空格、整段漏传,都必须是
// 400 而不是「200 + 把库里的编码清掉」。历史伤疤:漏传被当成清空,编码是导入匹配的键,
// 清掉之后导入只会报「单位不存在」,而现场当场看不出是刚才那次编辑干的。
func assertCodeNameRequired(t *testing.T, env *testEnv, client *http.Client, what, path, table string, id int64, base map[string]any) {
t.Helper()
for _, bad := range []struct {
label string
code string
name string
}{
{"空串", "", ""},
{"纯空格", " ", " "},
{"漏传", "", ""}, // code/name 两个键都不出现在请求体里
} {
body := map[string]any{}
for k, v := range base {
body[k] = v
}
if bad.label != "漏传" {
body["code"], body["name"] = bad.code, bad.name
}
status, resp := env.do(client, http.MethodPut, path, body, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s code/name %s 应 400 VALIDATION_FAILED,得到 %d %+v", what, bad.label, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "必填") {
t.Fatalf("%s code/name %s 的 400 文案应点明必填,得到 %q", what, bad.label, resp.Error.Message)
}
}
var code string
if err := env.ev.QueryRow(`SELECT code FROM `+table+` WHERE id = ?`, id).Scan(&code); err != nil {
t.Fatalf("读%s编码: %v", what, err)
}
if strings.TrimSpace(code) == "" {
t.Fatalf("%s 被拒的请求不该改库中编码,得到 %q", what, code)
}
}
// PUT 单位 / 大项 / 小项时,code 与 name 是必填:空串、纯空格、漏传一律 400,库里的编码不动。
// 改前这三类请求都是 200,落库 code=""(或 " ")——小项与单位的编码正是导入按
// team_code / event_code 匹配的键,清空之后导入永远报「不存在」。
func TestUpdateRequiresCodeAndName(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
assertCodeNameRequired(t, env, client, "单位", fmt.Sprintf("/api/teams/%d", ids.teamA), "teams", ids.teamA, nil)
assertCodeNameRequired(t, env, client, "大项", fmt.Sprintf("/api/sports/%d", ids.sport), "sports", ids.sport, nil)
assertCodeNameRequired(t, env, client, "小项", fmt.Sprintf("/api/events/%d", ids.event), "events", ids.event,
map[string]any{"sportId": ids.sport})
// 对照:合法的更新照常 200,且首尾空白被裁掉后再落库(编码「 A2 」与「A2」从此不可能并存)。
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/teams/%d", ids.teamA), map[string]any{
"code": " A2 ", "name": " 第一分公司 ", "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("带空白的合法更新应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "A2" || got["name"] != "第一分公司" {
t.Fatalf("更新应落裁过空白的值,得到 %+v", got)
}
// 新建侧同口径:POST 带空白的编码也按裁过的值落库。
status, resp = env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": " WS ", "name": " 带空格 ", "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建单位应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "WS" || got["name"] != "带空格" {
t.Fatalf("新建应落裁过空白的值,得到 %+v", got)
}
}
// A01:PUT /api/scoring-profiles/{id} 空 code/name 必须是 400。
// 改前它是 200:recorder 一按保存就能把模板的 code 清空,而 rules/validate.go 仍要求
// code 非空 —— admin 的 GET /api/rules/export 与 project.zip 从此永久 500,且没有任何提示。
func TestUpdateProfileCodeRequiredKeepsRulesExportUsable(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
admin, _ := env.client()
env.login(admin, "admin", "admin-pass")
rec := recorderClient(t, env)
rulesExport := func() int {
t.Helper()
resp, err := admin.Get(env.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("取规则包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
if got := rulesExport(); got != http.StatusOK {
t.Fatalf("改前规则包导出应 200,得到 %d", got)
}
for _, body := range []map[string]any{
{"code": "", "name": ""},
{"code": " ", "name": " "},
{"tieMode": "skip"}, // 漏传 code / name
} {
status, resp := env.do(rec, http.MethodPut, "/api/scoring-profiles/1", body, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%v 应 400 VALIDATION_FAILED,得到 %d %+v", body, status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "必填") {
t.Fatalf("400 文案应点明必填,得到 %q", resp.Error.Message)
}
}
var code string
if err := env.ev.QueryRow(`SELECT code FROM scoring_profiles WHERE id = 1`).Scan(&code); err != nil {
t.Fatalf("读模板编码: %v", err)
}
if code != "individual_top6" {
t.Fatalf("被拒的更新不该改库中编码,得到 %q", code)
}
if got := rulesExport(); got != http.StatusOK {
t.Fatalf("被拒的更新之后规则包导出仍应 200,得到 %d", got)
}
// 对照:合法的更新照常 200,落库的是裁掉空白后的值。
status, resp := env.do(rec, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": " P1 ", "name": " 个人项目前六名 ",
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("合法更新应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp); got["code"] != "P1" || got["name"] != "个人项目前六名" {
t.Fatalf("更新应落裁过空白的值,得到 %+v", got)
}
}
// profileRulesView 取模板的名次表,用来断言「被拒的更新没有把名次删空」。
type profileRulesView struct {
Rules []struct {
Rank int `json:"rank"`
} `json:"rules"`
}
func profileRuleRanks(t *testing.T, env *testEnv, client *http.Client, id int64) []int {
t.Helper()
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/scoring-profiles/%d", id), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读模板应 200,得到 %d %+v", status, resp.Error)
}
view := decodeData[profileRulesView](t, resp)
ranks := make([]int, 0, len(view.Rules))
for _, r := range view.Rules {
ranks = append(ranks, r.Rank)
}
return ranks
}
// rules[] 里两行同 rank 撞 scoring_profile_rules 主键 (profile_id, rank):
// 改前是 500 + 英文约束原文,现场只看到「服务端异常」,无从知道是哪一格填重了。
func TestProfileRulesDuplicateRankRejected(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
"code": "DUP-RANK", "name": "重复名次",
"rules": []map[string]any{{"rank": 1, "points": 10}, {"rank": 1, "points": 8}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("新建重复名次应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "重复") || !strings.Contains(resp.Error.Message, "1") {
t.Fatalf("400 文案应点明重复的名次,得到 %q", resp.Error.Message)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'DUP-RANK'`); n != 0 {
t.Fatalf("被拒的模板不该落库,得到 %d 行", n)
}
// 更新路径同口径;replaceProfileRules 是「先 DELETE 再 INSERT」,被拒时整个事务必须回滚,
// 否则模板会被存成「名次表空了」这种更坏的状态。
before := profileRuleRanks(t, env, client, 1)
if len(before) == 0 {
t.Fatalf("种子模板应自带名次表")
}
status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{
"code": "individual_top6", "name": "个人项目前六名",
"rules": []map[string]any{{"rank": 2, "points": 9}, {"rank": 2, "points": 8}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新重复名次应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "重复") || !strings.Contains(resp.Error.Message, "2") {
t.Fatalf("400 文案应点明重复的名次,得到 %q", resp.Error.Message)
}
if !strings.Contains(string(resp.Error.Detail), `"rank":2`) {
t.Fatalf("400 的 detail 应带上重复的那一行,得到 %s", resp.Error.Detail)
}
after := profileRuleRanks(t, env, client, 1)
if len(after) != len(before) {
t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after)
}
for i := range before {
if before[i] != after[i] {
t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after)
}
}
}
// defaultProfileId 指向不存在的模板:改前撞 scoring_profiles 的外键报 500「服务端异常」,
// 同类的 events.sport_id / channels.profile_id 都是 400。两条写入路径都要一致。
func TestSportDefaultProfileMustExist(t *testing.T) {
env := newTestEnv(t)
client := recorderClient(t, env)
status, resp := env.do(client, http.MethodPost, "/api/sports", map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 99999, "active": true,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("新建指向不存在的模板应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
if !strings.Contains(resp.Error.Message, "defaultProfileId") {
t.Fatalf("400 文案应点明字段名,得到 %q", resp.Error.Message)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM sports WHERE code = 'SWIM'`); n != 0 {
t.Fatalf("被拒的大项不该落库,得到 %d 行", n)
}
status, resp = env.do(client, http.MethodPost, "/api/sports", map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 1, "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("建大项应 200,得到 %d %+v", status, resp.Error)
}
sportID := int64(decodeData[map[string]any](t, resp)["id"].(float64))
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/sports/%d", sportID), map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 99999, "active": true,
}, true)
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("更新到不存在的模板应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
}
var profileID sql.NullInt64
if err := env.ev.QueryRow(`SELECT default_profile_id FROM sports WHERE id = ?`, sportID).Scan(&profileID); err != nil {
t.Fatalf("读默认模板: %v", err)
}
if !profileID.Valid || profileID.Int64 != 1 {
t.Fatalf("被拒的更新不该改默认模板,得到 %+v", profileID)
}
// 对照:指向真实模板照常可写。
status, resp = env.do(client, http.MethodPut, fmt.Sprintf("/api/sports/%d", sportID), map[string]any{
"code": "SWIM", "name": "游泳", "defaultProfileId": 2, "active": true,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("指向真实模板应 200,得到 %d %+v", status, resp.Error)
}
if err := env.ev.QueryRow(`SELECT default_profile_id FROM sports WHERE id = ?`, sportID).Scan(&profileID); err != nil {
t.Fatalf("读默认模板: %v", err)
}
if !profileID.Valid || profileID.Int64 != 2 {
t.Fatalf("合法更新应落库 2,得到 %+v", profileID)
}
}