Files
Scoreboard/server/internal/httpapi/system_test.go
T
AskaEth b5f40642ff feat: 公告持久化(底栏常驻)+ 团体可单独报名 + 榜单系数开关
公告:
- 服务端把当前公告落库到 settings(display.announce_text / announce_seconds),
  作为 display.announce 随 snapshot 与 /api/settings 下发(非 admin 快照也保留)
- announce 指令除转发外写库;新增 announce_clear 清空并广播;两者写审计
- realtime 协议登记 announce_clear(补上,使清除能即时推到大屏)
- 大屏把「全屏推送」与「底栏常驻」拆开:推送 seconds 秒后只退出全屏态,公告留在底栏,
  直到被替换或清除;新增公告状态机(可测)与底栏取值优先级
  推送中 > 服务端持久值 > 「暂无公告」;管理端加「清除公告」与当前公告状态

团体报名:
- EntryPicker 团体/对抗模式允许不勾队员直接报名该单位(此前必须勾人,导致
  「只想给单位报个团体项目」做不到、单位下没有运动员时完全报不了)
- 核实录入页团体行:显示/填名次/保存/发布/算分闭环无缺陷(用真实服务端跑过),
  抽出共享的 entryRowLabel 供两处复用

榜单口径:
- 基础数据 → 积分模板页新增「榜单口径(全局)」卡片:individualToTeamWeight
  数字输入(默认 1、0–100),说明「个人项目得分按该系数折算计入单位总分」,
  保存走 PUT /api/settings;因该值在服务端是全局键、模板表里没有此字段,
  故做成独立卡片并标注全局,不塞进模板表单

自检:服务端 11 包全绿;前端 vue-tsc + build、node --test 99/99。
2026-09-10 11:06:58 +08:00

348 lines
13 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 (
"encoding/json"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
)
// waitClosed 等待关闭回调触发;没触发就报错。
func waitClosed(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("等待关闭回调超时")
}
}
func assertNotClosed(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
t.Fatal("鉴权失败时不应触发关闭")
default:
}
}
// waitAuditAction 等待审计落库:Auditor 在 handler 返回后才写,存在极短竞态。
func waitAuditAction(t *testing.T, env *testEnv, action string) int {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
var count int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action).Scan(&count); err != nil {
t.Fatalf("查询审计: %v", err)
}
if count > 0 {
return count
}
if time.Now().After(deadline) {
t.Fatalf("等待审计 %s 超时", action)
}
time.Sleep(10 * time.Millisecond)
}
}
func newShutdownEnv(t *testing.T) (*testEnv, chan struct{}) {
t.Helper()
done := make(chan struct{})
var once sync.Once
return newTestEnvWith(t, func(o *httpapi.Options) {
o.Shutdown = func() { once.Do(func() { close(done) }) }
}), done
}
// POST /api/shutdown:admin 会话可关闭;guest 401、recorder 403;成功只回 {"ok":true} 且写审计。
func TestShutdownByAdminSession(t *testing.T) {
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "")
env, done := newShutdownEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
// 未登录:401,不触发关闭。
if status, env2 := env.do(client, http.MethodPost, "/api/shutdown", nil, false); status != http.StatusUnauthorized ||
env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 关闭应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
assertNotClosed(t, done)
// recorder:403,不触发关闭。
env.createUser("recorder", "recorder-pass", "recorder")
rec, _ := env.client()
env.login(rec, "recorder", "recorder-pass")
if status, env2 := env.do(rec, http.MethodPost, "/api/shutdown", nil, true); status != http.StatusForbidden ||
env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("recorder 关闭应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
assertNotClosed(t, done)
// admin:202 + 裸 {"ok":true}(不套 data 包装),并触发关闭回调。
env.login(client, "admin", "admin-pass")
status, raw := env.doRaw(client, http.MethodPost, "/api/shutdown", []byte("{}"), "application/json", true)
if status != http.StatusAccepted {
t.Fatalf("admin 关闭应 202,得到 %d %s", status, raw)
}
if got := strings.TrimSpace(string(raw)); got != `{"ok":true}` {
t.Fatalf("关闭响应应为 {\"ok\":true},得到 %s", got)
}
waitClosed(t, done)
if count := waitAuditAction(t, env, "system.shutdown"); count != 1 {
t.Fatalf("关闭应写 1 条 system.shutdown 审计,得到 %d", count)
}
var after string
if err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = 'system.shutdown' ORDER BY id DESC LIMIT 1`).Scan(&after); err != nil {
t.Fatalf("读取关闭审计: %v", err)
}
if !strings.Contains(after, `"session"`) {
t.Fatalf("关闭审计应标明凭据来源,得到 %s", after)
}
}
// 环境变量配置后:X-Shutdown-Token 正确即可关闭(无需会话),错误则 403。
func TestShutdownByToken(t *testing.T) {
t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "s3cret-token")
env, done := newShutdownEnv(t)
client, _ := env.client()
if status, _ := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "wrong"}); status != http.StatusForbidden {
t.Fatalf("错误 token 应 403,得到 %d", status)
}
assertNotClosed(t, done)
status, env2 := env.doH(client, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "s3cret-token"})
if status != http.StatusAccepted {
t.Fatalf("正确 token 应 202,得到 %d %+v", status, env2.Error)
}
waitClosed(t, done)
// 没注入关闭回调时应拒绝受理,避免谎报成功。
bare := newTestEnv(t)
bareClient, _ := bare.client()
if status, _ := bare.doH(bareClient, http.MethodPost, "/api/shutdown", nil, false,
map[string]string{"X-Shutdown-Token": "s3cret-token"}); status != http.StatusServiceUnavailable {
t.Fatalf("未接入关闭回调应 503,得到 %d", status)
}
}
// GET /api/system/about:版本包里的 buildTime 原样返回;未注入时是空串(前端据此显示「未注入」)。
// 顺带锁住权限:guest 依旧读不到(该端点靠它取内部署名)。
func TestSystemAboutReturnsBuildTime(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/system/about", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读 about 应 403,得到 %d", status)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 模拟 ldflags 注入:直接改包级变量,用例结束后恢复,避免影响其他用例。
original := version.BuildTime
t.Cleanup(func() { version.BuildTime = original })
version.BuildTime = "2026-09-10 08:50:12 +08:00"
status, env2 := env.do(client, http.MethodGet, "/api/system/about", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读 about 应 200,得到 %d %+v", status, env2.Error)
}
var about map[string]any
if err := json.Unmarshal(env2.Data, &about); err != nil {
t.Fatalf("解析 about: %v (%s)", err, env2.Data)
}
if about["buildTime"] != "2026-09-10 08:50:12 +08:00" {
t.Fatalf("buildTime 应原样返回注入值,得到 %#v", about["buildTime"])
}
if _, ok := about["internalSignature"]; !ok {
t.Fatalf("about 仍应带 internalSignature: %v", about)
}
// 未注入时字段仍在、值为空串,前端才能稳定判断「未注入」而不是解析失败。
version.BuildTime = ""
_, env2 = env.do(client, http.MethodGet, "/api/system/about", nil, false)
if err := json.Unmarshal(env2.Data, &about); err != nil {
t.Fatalf("解析 about: %v", err)
}
if got, ok := about["buildTime"]; !ok || got != "" {
t.Fatalf("未注入时 buildTime 应为空串,得到 %#v(存在=%v)", got, ok)
}
}
// GET /api/rules/switches:recorder 可读、guest 403,只返回布尔开关。
func TestRuleSwitchesReadableByRecorder(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/rules/switches", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读规则开关应 403,得到 %d", status)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读规则开关应 200,得到 %d %+v", status, env2.Error)
}
var switches map[string]any
if err := json.Unmarshal(env2.Data, &switches); err != nil {
t.Fatalf("解析规则开关: %v (%s)", err, env2.Data)
}
want := []string{
"recordBonusEnabled", "adjustmentsEnabled", "adjustmentReasonRequired",
"medalCountsTeamEvents", "showIndividualBoard", "showIndividualMedals",
}
if len(switches) != len(want) {
t.Fatalf("只应返回布尔开关,得到 %v", switches)
}
for _, key := range want {
v, ok := switches[key]
if !ok {
t.Fatalf("缺少开关 %s: %v", key, switches)
}
if _, isBool := v.(bool); !isBool {
t.Fatalf("开关 %s 应为布尔值,得到 %T", key, v)
}
}
// 默认:破纪录加分与人工调整关闭,理由必填、奖牌统计与两块个人榜开关开启。
if switches["recordBonusEnabled"] != false || switches["adjustmentsEnabled"] != false ||
switches["adjustmentReasonRequired"] != true || switches["medalCountsTeamEvents"] != true ||
switches["showIndividualBoard"] != true || switches["showIndividualMedals"] != true {
t.Fatalf("默认开关取值不符: %v", switches)
}
// 规则导入/配置改动后开关随之变化。
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'record_bonus.enabled'`)
env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`)
_, env2 = env.do(client, http.MethodGet, "/api/rules/switches", nil, false)
if err := json.Unmarshal(env2.Data, &switches); err != nil {
t.Fatalf("解析规则开关: %v", err)
}
if switches["recordBonusEnabled"] != true || switches["adjustmentsEnabled"] != true {
t.Fatalf("开关应随 settings 变化: %v", switches)
}
}
// GET /api/settings 与 /api/standings 的 JSON 字段名是对外契约,收敛 DTO 时不能变。
func TestSettingsAndStandingsJSONContract(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
_, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
var settings map[string]any
if err := json.Unmarshal(env2.Data, &settings); err != nil {
t.Fatalf("解析 settings: %v", err)
}
assertKeys(t, "settings", settings, []string{"event", "display", "standings", "defaultProfileId", "requireApproval"})
assertKeys(t, "settings.event", asMap(t, settings["event"]),
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
assertKeys(t, "settings.display", asMap(t, settings["display"]),
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce"})
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
var rows []map[string]any
if err := json.Unmarshal(env2.Data, &rows); err != nil {
t.Fatalf("解析 standings: %v", err)
}
if len(rows) == 0 {
t.Fatal("榜单不应为空")
}
assertKeys(t, "standings[0]", rows[0], []string{
"teamId", "teamCode", "teamName", "shortName", "color", "logoPath",
"rank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange",
})
}
func asMap(t *testing.T, v any) map[string]any {
t.Helper()
m, ok := v.(map[string]any)
if !ok {
t.Fatalf("期望对象,得到 %T", v)
}
return m
}
func assertKeys(t *testing.T, name string, got map[string]any, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s 字段集不符:want=%v got=%v", name, want, got)
}
for _, key := range want {
if _, ok := got[key]; !ok {
t.Fatalf("%s 缺少字段 %s:%v", name, key, got)
}
}
}
type settingsStandings struct {
Standings struct {
ShowIndividualBoard bool `json:"showIndividualBoard"`
ShowIndividualMedals bool `json:"showIndividualMedals"`
} `json:"standings"`
}
// standings.showIndividualMedals 贯通 /api/settings 读写,且不破坏 showIndividualBoard 的别名语义。
func TestSettingsShowIndividualMedals(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK {
t.Fatalf("读配置应 200,得到 %d", status)
}
settings := decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
t.Fatalf("默认两个开关都应为 true: %+v", settings.Standings)
}
// 旧客户端只传 showIndividualBoard:不得影响新的 showIndividualMedals。
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
"standings": map[string]any{"showIndividualBoard": false},
}, true)
if status != http.StatusOK {
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
}
settings = decodeData[settingsStandings](t, env2)
if settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals {
t.Fatalf("只改 board 不应影响 medals: %+v", settings.Standings)
}
// 两个开关可以独立配置:board=true + medals=false。
status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{
"standings": map[string]any{"showIndividualBoard": true, "showIndividualMedals": false},
}, true)
if status != http.StatusOK {
t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error)
}
settings = decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
t.Fatalf("两个开关应可独立配置: %+v", settings.Standings)
}
// 落库并复读。
_, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
settings = decodeData[settingsStandings](t, env2)
if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals {
t.Fatalf("复读配置不符: %+v", settings.Standings)
}
}