- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
339 lines
13 KiB
Go
339 lines
13 KiB
Go
package httpapi_test
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
type seedIDs struct {
|
|
teamA, teamB int64
|
|
athleteA, athB int64
|
|
sport, event int64
|
|
category, entryA int64
|
|
entryB int64
|
|
}
|
|
|
|
func (e *testEnv) seed(t *testing.T) seedIDs {
|
|
t.Helper()
|
|
var ids seedIDs
|
|
|
|
ids.teamA = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('A', '第一分公司', '一司', 0, 1)`)
|
|
ids.teamB = e.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES ('B', '第二分公司', '二司', 1, 1)`)
|
|
ids.athleteA = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
|
|
ids.athB = e.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('李强', '男', ?, 1)`, ids.teamB)
|
|
ids.sport = e.insert(t, `INSERT INTO sports (code, name, sort_order, active) VALUES ('TRACK', '田径', 0, 1)`)
|
|
ids.event = e.insert(t,
|
|
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
|
VALUES (?, 'M100', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
|
ids.category = e.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ids.event)
|
|
|
|
var profileID int64
|
|
if err := e.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
|
|
t.Fatalf("读取积分模板: %v", err)
|
|
}
|
|
e.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
|
VALUES (?, 'athlete', 'result', ?, 0)`, ids.event, profileID)
|
|
|
|
ids.entryA = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
|
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, ids.athleteA)
|
|
ids.entryB = e.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
|
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamB, ids.athB)
|
|
return ids
|
|
}
|
|
|
|
func (e *testEnv) exec(t *testing.T, query string, args ...any) {
|
|
t.Helper()
|
|
if _, err := e.ev.Exec(query, args...); err != nil {
|
|
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
|
|
}
|
|
}
|
|
|
|
func (e *testEnv) insert(t *testing.T, query string, args ...any) int64 {
|
|
t.Helper()
|
|
res, err := e.ev.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
|
|
}
|
|
|
|
// 未登录 = guest:赛事数据可读,系统配置一律 403。
|
|
func TestGuestReadsEventDataButNotConfig(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
env.seed(t)
|
|
client, _ := env.client()
|
|
|
|
if status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false); status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("guest 读积分榜应 200,得到 status=%d ok=%v", status, env2.OK)
|
|
}
|
|
if status, env2 := env.do(client, http.MethodGet, "/api/teams", nil, false); status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("guest 读单位应 200,得到 status=%d ok=%v", status, env2.OK)
|
|
}
|
|
|
|
for _, path := range []string{"/api/settings", "/api/audit-logs"} {
|
|
status, env2 := env.do(client, http.MethodGet, path, nil, false)
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("guest 读 %s 应 403,得到 %d", path, status)
|
|
}
|
|
if env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
|
|
t.Fatalf("guest 读 %s 应返回 FORBIDDEN,得到 %+v", path, env2.Error)
|
|
}
|
|
}
|
|
|
|
// 未登录写操作要求先登录。
|
|
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
|
|
"code": "C", "name": "第三分公司", "active": true,
|
|
}, false)
|
|
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
|
|
t.Fatalf("guest 写操作应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
|
|
}
|
|
}
|
|
|
|
// recorder 拥有录入权限,但没有系统配置权限。
|
|
func TestRecorderForbiddenOnAdminRoutes(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
env.seed(t)
|
|
env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
|
"event": map[string]any{"name": "改名"},
|
|
}, true)
|
|
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
|
|
t.Fatalf("recorder 改配置应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
|
|
}
|
|
if status, _ := env.do(client, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden {
|
|
t.Fatalf("recorder 读审计应 403,得到 %d", status)
|
|
}
|
|
|
|
// recorder 的合法写操作仍然可用。
|
|
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("recorder 建单位应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
}
|
|
|
|
// 登录成功后服务端建会话(sessions 表 + HttpOnly Cookie)。
|
|
func TestLoginCreatesSession(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
userID := env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
var count int
|
|
if err := env.sys.QueryRow(`SELECT COUNT(*) FROM sessions WHERE user_id = ?`, userID).Scan(&count); err != nil {
|
|
t.Fatalf("查询会话: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("登录后应有 1 条会话,得到 %d", count)
|
|
}
|
|
|
|
status, env2 := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("me 应 200,得到 %d", status)
|
|
}
|
|
me := decodeData[map[string]any](t, env2)
|
|
if me["username"] != "recorder" || me["role"] != "recorder" {
|
|
t.Fatalf("me 返回身份不对: %+v", me)
|
|
}
|
|
|
|
if status, _ := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
|
|
t.Fatalf("登出应 200,得到 %d", status)
|
|
}
|
|
status, env2 = env.do(client, http.MethodGet, "/api/auth/me", nil, false)
|
|
me = decodeData[map[string]any](t, env2)
|
|
if status != http.StatusOK || me["role"] != "guest" {
|
|
t.Fatalf("登出后应为 guest,得到 %d %+v", status, me)
|
|
}
|
|
}
|
|
|
|
// 首次启动创建默认管理员并打印随机初始密码。
|
|
func TestEnsureDefaultAdmin(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
username, password, created, err := env.auth.EnsureDefaultAdmin(t.Context())
|
|
if err != nil {
|
|
t.Fatalf("创建默认管理员: %v", err)
|
|
}
|
|
if !created || username == "" || len(password) < 12 {
|
|
t.Fatalf("应创建默认管理员并返回随机密码,得到 created=%v user=%q passLen=%d", created, username, len(password))
|
|
}
|
|
client, _ := env.client()
|
|
env.login(client, username, password)
|
|
}
|
|
|
|
// 发布成绩:写流水、写审计(操作人/IP/request_id/before/after)、积分榜生效。
|
|
func TestPublishWritesLedgerAndAudit(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
ids := env.seed(t)
|
|
userID := env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
|
"eventId": ids.event,
|
|
"requestId": "draft-1",
|
|
"results": []map[string]any{
|
|
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
|
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
|
},
|
|
}, true)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
|
|
status, env2 = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
|
"eventId": ids.event,
|
|
"requestId": "publish-1",
|
|
}, true)
|
|
if status != http.StatusOK || !env2.OK {
|
|
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
|
}
|
|
results := decodeData[[]map[string]any](t, env2)
|
|
if len(results) != 2 {
|
|
t.Fatalf("发布应返回 2 条成绩,得到 %d", len(results))
|
|
}
|
|
for _, r := range results {
|
|
if r["state"] != "published" {
|
|
t.Fatalf("成绩应为 published: %+v", r)
|
|
}
|
|
}
|
|
|
|
// 流水:A 10 分、B 8 分。
|
|
rows, err := env.ev.Query(
|
|
`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result' ORDER BY team_id`)
|
|
if err != nil {
|
|
t.Fatalf("查询流水: %v", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
points := map[int64]float64{}
|
|
for rows.Next() {
|
|
var team int64
|
|
var p float64
|
|
if err := rows.Scan(&team, &p); err != nil {
|
|
t.Fatalf("扫描流水: %v", err)
|
|
}
|
|
points[team] = p
|
|
}
|
|
if points[ids.teamA] != 10 || points[ids.teamB] != 8 {
|
|
t.Fatalf("流水分值不对: %+v", points)
|
|
}
|
|
|
|
// 积分榜:A 第一。
|
|
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
|
standings := decodeData[[]map[string]any](t, env2)
|
|
if len(standings) != 2 || standings[0]["teamCode"] != "A" || standings[0]["totalPoints"].(float64) != 10 {
|
|
t.Fatalf("积分榜不对: %+v", standings)
|
|
}
|
|
if standings[0]["gold"].(float64) != 1 {
|
|
t.Fatalf("A 应记 1 金: %+v", standings[0])
|
|
}
|
|
|
|
// 审计:操作人、IP、request_id、before/after 都要有。
|
|
var (
|
|
action string
|
|
auditUser sql.NullInt64
|
|
ip, requestID sql.NullString
|
|
before, after sql.NullString
|
|
)
|
|
err = env.ev.QueryRow(
|
|
`SELECT action, user_id, ip, request_id, before_json, after_json
|
|
FROM audit_logs WHERE action = 'result.publish' ORDER BY id DESC LIMIT 1`).
|
|
Scan(&action, &auditUser, &ip, &requestID, &before, &after)
|
|
if err != nil {
|
|
t.Fatalf("查询审计: %v", err)
|
|
}
|
|
if !auditUser.Valid || auditUser.Int64 != userID {
|
|
t.Fatalf("审计操作人应为 %d,得到 %+v", userID, auditUser)
|
|
}
|
|
if !ip.Valid || ip.String == "" {
|
|
t.Fatalf("审计应记录 IP")
|
|
}
|
|
if !requestID.Valid || requestID.String == "" {
|
|
t.Fatalf("审计应记录 request_id")
|
|
}
|
|
if !before.Valid || !after.Valid {
|
|
t.Fatalf("审计应记录 before/after,得到 before=%v after=%v", before, after)
|
|
}
|
|
var parsed []map[string]any
|
|
if err := json.Unmarshal([]byte(after.String), &parsed); err != nil {
|
|
t.Fatalf("审计 after_json 应为成绩数组: %v", err)
|
|
}
|
|
}
|
|
|
|
// 前端会在每个写请求体里注入 requestId,服务端必须接受而不是 400;
|
|
// 同一个 X-Request-Id 重复提交应回放首次结果。
|
|
func TestWriteAcceptsRequestBodyRequestIDAndReplays(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
env.createUser("recorder", "recorder-pass", "recorder")
|
|
client, _ := env.client()
|
|
env.login(client, "recorder", "recorder-pass")
|
|
|
|
body := map[string]any{"code": "A", "name": "第一分公司", "active": true, "requestId": "team-create-1"}
|
|
status, first := env.do(client, http.MethodPost, "/api/teams", body, true)
|
|
if status != http.StatusOK || !first.OK {
|
|
t.Fatalf("带 requestId 的写请求应 200,得到 %d %+v", status, first.Error)
|
|
}
|
|
|
|
headers := map[string]string{"X-Request-Id": "replay-key-1"}
|
|
status, second := env.doH(client, http.MethodPost, "/api/teams",
|
|
map[string]any{"code": "B", "name": "第二分公司", "active": true}, true, headers)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("首次带 X-Request-Id 的写请求应 200,得到 %d", status)
|
|
}
|
|
status, replay := env.doH(client, http.MethodPost, "/api/teams",
|
|
map[string]any{"code": "C", "name": "第三分公司", "active": true}, true, headers)
|
|
if status != http.StatusOK || string(replay.Data) != string(second.Data) {
|
|
t.Fatalf("同一 X-Request-Id 应回放首次结果,得到 %d\nsecond=%s\nreplay=%s", status, second.Data, replay.Data)
|
|
}
|
|
|
|
var teams int
|
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM teams`).Scan(&teams); err != nil {
|
|
t.Fatalf("统计单位: %v", err)
|
|
}
|
|
if teams != 2 {
|
|
t.Fatalf("幂等回放不应重复建单位,得到 %d 个", teams)
|
|
}
|
|
}
|
|
|
|
// 写操作幂等:同一个 requestId 重复提交返回首次结果,不重复执行。
|
|
func TestPublishIdempotentReplay(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")
|
|
|
|
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
|
"eventId": ids.event,
|
|
"requestId": "draft-1",
|
|
"results": []map[string]any{
|
|
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
|
|
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
|
|
},
|
|
}, true)
|
|
|
|
body := map[string]any{"eventId": ids.event, "requestId": "publish-same"}
|
|
_, first := env.do(client, http.MethodPost, "/api/results/publish", body, true)
|
|
_, second := env.do(client, http.MethodPost, "/api/results/publish", body, true)
|
|
if string(first.Data) != string(second.Data) {
|
|
t.Fatalf("重复提交应回放首次结果\nfirst=%s\nsecond=%s", first.Data, second.Data)
|
|
}
|
|
|
|
var ledgerRows int
|
|
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&ledgerRows); err != nil {
|
|
t.Fatalf("统计流水: %v", err)
|
|
}
|
|
if ledgerRows != 2 {
|
|
t.Fatalf("重复提交不应重复写流水,得到 %d 条", ledgerRows)
|
|
}
|
|
}
|