服务端一批「会误导现场」的缺陷,全部补了测试: - 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、 scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status) 过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成 服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED, message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。 - 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的 原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文, 日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数, 拦截点不再退化成 response.go 里的构造函数。 - /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」), 超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号, 数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。 - 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential): 401 的语义是「本请求没有有效会话」,而这里会话是好的。 - 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动, 否则堆键数就能冲掉别人的计数)。 - createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。 数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results / score_ledger 的级联,风险高不做),这一层先兜住。 - 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、 5xx 不外泄原文、限流键清理不误删。
182 lines
6.1 KiB
Go
182 lines
6.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
|
)
|
|
|
|
// createEntry / updateEntry 目前没有挂路由,只能在包内直接调用验证。
|
|
func newEntryTestServer(t *testing.T) (*server, *sql.DB) {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite",
|
|
"file:"+filepath.Join(t.TempDir(), "event.db")+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
|
|
if err != nil {
|
|
t.Fatalf("打开赛事库: %v", err)
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if err := store.MigrateEvent(db); err != nil {
|
|
t.Fatalf("迁移赛事库: %v", err)
|
|
}
|
|
return &server{ev: db, logger: slog.New(slog.NewTextHandler(io.Discard, nil))}, db
|
|
}
|
|
|
|
type entryFixture struct {
|
|
eventA, eventB int64
|
|
catA, catB int64
|
|
team int64
|
|
}
|
|
|
|
func seedEntryFixture(t *testing.T, db *sql.DB) entryFixture {
|
|
t.Helper()
|
|
exec := func(query string, args ...any) int64 {
|
|
res, err := db.Exec(query, args...)
|
|
if err != nil {
|
|
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
t.Fatalf("取 id 失败: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
sport := exec(`INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
|
|
var f entryFixture
|
|
f.eventA = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'A100', '甲组100米', 'pending')`, sport)
|
|
f.eventB = exec(`INSERT INTO events (sport_id, code, name, status) VALUES (?, 'B100', '乙组100米', 'pending')`, sport)
|
|
f.catA = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子A', 1)`, f.eventA)
|
|
f.catB = exec(`INSERT INTO event_categories (event_id, name, multiplier) VALUES (?, '男子B', 1)`, f.eventB)
|
|
f.team = exec(`INSERT INTO teams (code, name) VALUES ('T1', '第一队')`)
|
|
return f
|
|
}
|
|
|
|
func requireAPIStatus(t *testing.T, err error, status int) *apiError {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatalf("期望错误,得到 nil")
|
|
}
|
|
var api *apiError
|
|
if !errors.As(err, &api) {
|
|
t.Fatalf("期望 *apiError,得到 %T: %v", err, err)
|
|
}
|
|
if api.status != status {
|
|
t.Fatalf("期望状态 %d,得到 %d (%s)", status, api.status, api.message)
|
|
}
|
|
return api
|
|
}
|
|
|
|
func TestNormalizeEntryStatus(t *testing.T) {
|
|
for _, tc := range []struct{ raw, want string }{
|
|
{"", "registered"},
|
|
{"dns", "dns"},
|
|
{" scratched ", "scratched"},
|
|
} {
|
|
got, err := normalizeEntryStatus(tc.raw)
|
|
if err != nil || got != tc.want {
|
|
t.Fatalf("normalizeEntryStatus(%q) = %q, %v;期望 %q", tc.raw, got, err, tc.want)
|
|
}
|
|
}
|
|
|
|
got, err := normalizeEntryStatus("bogus")
|
|
if got != "" || err == nil {
|
|
t.Fatalf("非法 status 应报错,得到 %q, %v", got, err)
|
|
}
|
|
api := requireAPIStatus(t, err, http.StatusBadRequest)
|
|
if api.code != "VALIDATION_FAILED" {
|
|
t.Fatalf("非法 status 的 code 应是 VALIDATION_FAILED,得到 %q", api.code)
|
|
}
|
|
if !strings.Contains(api.message, "status") {
|
|
t.Fatalf("错误文案应带字段名,得到 %q", api.message)
|
|
}
|
|
if api.detail != "bogus" {
|
|
t.Fatalf("detail 应是原始值,得到 %v", api.detail)
|
|
}
|
|
}
|
|
|
|
func TestCreateEntryRejectsForeignCategory(t *testing.T) {
|
|
s, db := newEntryTestServer(t)
|
|
f := seedEntryFixture(t, db)
|
|
ctx := context.Background()
|
|
|
|
_, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catB, TeamID: f.team, Status: "registered"})
|
|
api := requireAPIStatus(t, err, http.StatusBadRequest)
|
|
if api.message != "categoryId 不属于该小项" {
|
|
t.Fatalf("错误文案应是「categoryId 不属于该小项」,得到 %q", api.message)
|
|
}
|
|
if api.detail != f.catB {
|
|
t.Fatalf("detail 应是 categoryId %d,得到 %v", f.catB, api.detail)
|
|
}
|
|
|
|
var n int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM entries`).Scan(&n); err != nil {
|
|
t.Fatalf("统计报名项: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("组别不属于小项时不该落库,entries 有 %d 行", n)
|
|
}
|
|
|
|
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "dq"})
|
|
if err != nil {
|
|
t.Fatalf("同小项组别应创建成功: %v", err)
|
|
}
|
|
if created.CategoryID != f.catA || created.EventID != f.eventA || created.Status != "dq" {
|
|
t.Fatalf("报名项字段不对: %+v", created)
|
|
}
|
|
}
|
|
|
|
func TestCreateEntryRejectsUnknownStatus(t *testing.T) {
|
|
s, db := newEntryTestServer(t)
|
|
f := seedEntryFixture(t, db)
|
|
|
|
_, err := s.createEntry(context.Background(),
|
|
entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "bogus"})
|
|
api := requireAPIStatus(t, err, http.StatusBadRequest)
|
|
if api.code != "VALIDATION_FAILED" || !strings.Contains(api.message, "status") {
|
|
t.Fatalf("非法 status 应 400 VALIDATION_FAILED 且带字段名,得到 %+v", api)
|
|
}
|
|
}
|
|
|
|
func TestUpdateEntryCategoryCheckKeepsNotFound(t *testing.T) {
|
|
s, db := newEntryTestServer(t)
|
|
f := seedEntryFixture(t, db)
|
|
ctx := context.Background()
|
|
|
|
created, err := s.createEntry(ctx, entryDTO{EventID: f.eventA, CategoryID: f.catA, TeamID: f.team, Status: "registered"})
|
|
if err != nil {
|
|
t.Fatalf("准备报名项失败: %v", err)
|
|
}
|
|
|
|
// 组别必须属于该报名项所在小项(updateEntry 不改 event_id)。
|
|
_, err = s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catB, TeamID: f.team, Status: "registered"})
|
|
api := requireAPIStatus(t, err, http.StatusBadRequest)
|
|
if api.message != "categoryId 不属于该小项" || api.detail != f.catB {
|
|
t.Fatalf("非法组别应被拦下,得到 %+v", api)
|
|
}
|
|
|
|
updated, err := s.updateEntry(ctx, created.ID, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "scratched"})
|
|
if err != nil {
|
|
t.Fatalf("同小项组别应更新成功: %v", err)
|
|
}
|
|
if updated.CategoryID != f.catA || updated.Status != "scratched" {
|
|
t.Fatalf("更新结果不对: %+v", updated)
|
|
}
|
|
|
|
// 报名项不存在仍是 404,不能被组别校验降级成 400。
|
|
_, err = s.updateEntry(ctx, created.ID+9999, entryDTO{CategoryID: f.catA, TeamID: f.team, Status: "registered"})
|
|
api = requireAPIStatus(t, err, http.StatusNotFound)
|
|
if api.message != "报名项不存在" {
|
|
t.Fatalf("不存在的报名项应 404「报名项不存在」,得到 %+v", api)
|
|
}
|
|
}
|