Files
Scoreboard/server/internal/httpapi/repo_test.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

493 lines
21 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)
}
}
}