## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.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)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
1680 lines
61 KiB
Go
1680 lines
61 KiB
Go
package realtime
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"reflect"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/coder/websocket"
|
||
_ "modernc.org/sqlite"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||
)
|
||
|
||
// ---------- 测试脚手架 ----------
|
||
|
||
type testEnv struct {
|
||
hub *Hub
|
||
db *sql.DB
|
||
srv *httptest.Server
|
||
}
|
||
|
||
// newTestEnv 建一个未登录(guest)的服务端:默认身份与大屏端一致。
|
||
func newTestEnv(t *testing.T) *testEnv {
|
||
t.Helper()
|
||
return newTestEnvAs(t, &auth.User{Role: auth.RoleGuest})
|
||
}
|
||
|
||
// newTestEnvAs 注入会话身份,等价于真实链路里 Authenticate 中间件写进 context 的 User。
|
||
func newTestEnvAs(t *testing.T, u *auth.User) *testEnv {
|
||
t.Helper()
|
||
return newTestEnvAsSession(t, u, "")
|
||
}
|
||
|
||
// newTestEnvAsSession 在身份之外再注入会话 id,等价于 Authenticate 中间件写进 context 的
|
||
// *auth.Session:会话复查(撤销即断开)要按会话 id 判定,测试必须能给出这一条。
|
||
func newTestEnvAsSession(t *testing.T, u *auth.User, sessionID string) *testEnv {
|
||
t.Helper()
|
||
return newTestEnvFull(t, u, sessionID, io.Discard)
|
||
}
|
||
|
||
// newTestEnvFull 与 newTestEnvAsSession 相同,但把实时层的日志写进调用方给的 writer,
|
||
// 且放行 Debug:需要断言「这条路径不该刷 WARN/ERROR」的用例(如恢复窗口降级)用它。
|
||
// 其余用例传 io.Discard,行为与以前一致。
|
||
func newTestEnvFull(t *testing.T, u *auth.User, sessionID string, logw io.Writer) *testEnv {
|
||
t.Helper()
|
||
|
||
db := newTestDB(t)
|
||
h := NewHub(db, slog.New(slog.NewTextHandler(logw, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||
handler := h.Handler()
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
ctx := middleware.WithUser(r.Context(), u)
|
||
if sessionID != "" {
|
||
ctx = middleware.WithSession(ctx, &auth.Session{ID: sessionID, UserID: u.ID})
|
||
}
|
||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||
}))
|
||
|
||
t.Cleanup(func() {
|
||
srv.Close()
|
||
_ = h.Close()
|
||
_ = db.Close()
|
||
})
|
||
return &testEnv{hub: h, db: db, srv: srv}
|
||
}
|
||
|
||
func newTestDB(t *testing.T) *sql.DB {
|
||
t.Helper()
|
||
|
||
path := filepath.Join(t.TempDir(), "event.db")
|
||
db, err := sql.Open("sqlite", "file:"+path+
|
||
"?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
|
||
if err != nil {
|
||
t.Fatalf("打开测试库: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = db.Close() })
|
||
|
||
if err := store.MigrateEvent(db); err != nil {
|
||
t.Fatalf("迁移测试库: %v", err)
|
||
}
|
||
return db
|
||
}
|
||
|
||
func (e *testEnv) dial(t *testing.T, hello *helloPayload) *wsClient {
|
||
t.Helper()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
url := "ws" + strings.TrimPrefix(e.srv.URL, "http") + "/ws"
|
||
conn, _, err := websocket.Dial(ctx, url, nil)
|
||
if err != nil {
|
||
cancel()
|
||
t.Fatalf("连接 /ws: %v", err)
|
||
}
|
||
|
||
c := &wsClient{t: t, ctx: ctx, cancel: cancel, conn: conn, msgs: make(chan wsMessage, 256)}
|
||
go c.readLoop()
|
||
t.Cleanup(c.close)
|
||
if hello != nil {
|
||
c.send(EventHello, *hello)
|
||
}
|
||
return c
|
||
}
|
||
|
||
type wsMessage struct {
|
||
event string
|
||
data json.RawMessage
|
||
err error
|
||
closeCode websocket.StatusCode
|
||
}
|
||
|
||
type wsClient struct {
|
||
t *testing.T
|
||
ctx context.Context
|
||
cancel context.CancelFunc
|
||
conn *websocket.Conn
|
||
msgs chan wsMessage
|
||
}
|
||
|
||
// readLoop 单独读取:测试里等待「没有消息」时不能靠取消 Read 的 context,
|
||
// 那会把整条连接关掉,后续用例就没法继续读了。
|
||
func (c *wsClient) readLoop() {
|
||
defer close(c.msgs)
|
||
for {
|
||
_, data, err := c.conn.Read(c.ctx)
|
||
if err != nil {
|
||
c.msgs <- wsMessage{err: err, closeCode: websocket.CloseStatus(err)}
|
||
return
|
||
}
|
||
var env envelope
|
||
if err := json.Unmarshal(data, &env); err != nil {
|
||
c.msgs <- wsMessage{err: err}
|
||
return
|
||
}
|
||
c.msgs <- wsMessage{event: env.Event, data: env.Data}
|
||
}
|
||
}
|
||
|
||
func (c *wsClient) close() {
|
||
_ = c.conn.CloseNow()
|
||
c.cancel()
|
||
}
|
||
|
||
func (c *wsClient) send(event string, data any) {
|
||
c.t.Helper()
|
||
msg, err := encode(event, data)
|
||
if err != nil {
|
||
c.t.Fatalf("编码 %s: %v", event, err)
|
||
}
|
||
ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
|
||
defer cancel()
|
||
if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil {
|
||
c.t.Fatalf("发送 %s: %v", event, err)
|
||
}
|
||
}
|
||
|
||
// recvWithin 在 d 内取一条消息;超时返回 ok=false(连接保持可用)。
|
||
func (c *wsClient) recvWithin(d time.Duration) (string, json.RawMessage, bool) {
|
||
c.t.Helper()
|
||
timer := time.NewTimer(d)
|
||
defer timer.Stop()
|
||
|
||
select {
|
||
case msg, ok := <-c.msgs:
|
||
if !ok || msg.err != nil {
|
||
c.t.Fatalf("连接已断开: %v", msg.err)
|
||
}
|
||
return msg.event, msg.data, true
|
||
case <-timer.C:
|
||
return "", nil, false
|
||
}
|
||
}
|
||
|
||
// recvEvent 等待指定事件,跳过心跳等无关消息。
|
||
func (c *wsClient) recvEvent(want string) json.RawMessage {
|
||
c.t.Helper()
|
||
deadline := time.Now().Add(5 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
ev, data, ok := c.recvWithin(time.Until(deadline))
|
||
if !ok {
|
||
break
|
||
}
|
||
if ev == want {
|
||
return data
|
||
}
|
||
}
|
||
c.t.Fatalf("等待事件 %s 超时", want)
|
||
return nil
|
||
}
|
||
|
||
// awaitSnapshot 在 d 内等待一份整库快照(心跳等无关消息跳过);等到返回 true。
|
||
func (c *wsClient) awaitSnapshot(d time.Duration) bool {
|
||
c.t.Helper()
|
||
deadline := time.Now().Add(d)
|
||
for time.Now().Before(deadline) {
|
||
ev, _, ok := c.recvWithin(time.Until(deadline))
|
||
if !ok {
|
||
return false
|
||
}
|
||
if ev == EventSnapshot {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// noSnapshotWithin 断言 d 内没有整库快照;心跳等无关消息不算(连接断开则直接失败)。
|
||
func (c *wsClient) noSnapshotWithin(d time.Duration) {
|
||
c.t.Helper()
|
||
deadline := time.Now().Add(d)
|
||
for time.Now().Before(deadline) {
|
||
ev, _, ok := c.recvWithin(time.Until(deadline))
|
||
if !ok {
|
||
return
|
||
}
|
||
if ev == EventSnapshot {
|
||
c.t.Fatalf("这段时间内不该有整库快照")
|
||
}
|
||
}
|
||
}
|
||
|
||
// expectClosed 断言连接在 d 内被服务端关闭,且关闭前没有下发任何事件。
|
||
func (c *wsClient) expectClosed(d time.Duration) {
|
||
c.t.Helper()
|
||
timer := time.NewTimer(d)
|
||
defer timer.Stop()
|
||
for {
|
||
select {
|
||
case msg, ok := <-c.msgs:
|
||
if !ok || msg.err != nil {
|
||
return
|
||
}
|
||
c.t.Fatalf("连接关闭前仍收到事件 %s", msg.event)
|
||
case <-timer.C:
|
||
c.t.Fatalf("等待服务端关闭连接超时")
|
||
}
|
||
}
|
||
}
|
||
|
||
// expectClosedWithCode 断言连接被服务端以指定关闭码关闭,且关闭前没有下发任何事件。
|
||
func (c *wsClient) expectClosedWithCode(d time.Duration, want websocket.StatusCode) {
|
||
c.t.Helper()
|
||
timer := time.NewTimer(d)
|
||
defer timer.Stop()
|
||
for {
|
||
select {
|
||
case msg, ok := <-c.msgs:
|
||
if !ok {
|
||
c.t.Fatalf("连接已关闭但没有收到关闭帧")
|
||
}
|
||
if msg.err != nil {
|
||
if msg.closeCode != want {
|
||
c.t.Fatalf("关闭码应为 %d,得到 %d (%v)", want, msg.closeCode, msg.err)
|
||
}
|
||
return
|
||
}
|
||
c.t.Fatalf("连接关闭前仍收到事件 %s", msg.event)
|
||
case <-timer.C:
|
||
c.t.Fatalf("等待服务端关闭连接超时")
|
||
}
|
||
}
|
||
}
|
||
|
||
// expectClosedWithCodeDraining 断言连接最终被服务端以指定关闭码关闭。
|
||
// 关闭前已下发的正常事件会被丢弃:慢客户端被踢属于「先推后关」,不能要求关闭前零事件。
|
||
func (c *wsClient) expectClosedWithCodeDraining(d time.Duration, want websocket.StatusCode) {
|
||
c.t.Helper()
|
||
timer := time.NewTimer(d)
|
||
defer timer.Stop()
|
||
for {
|
||
select {
|
||
case msg, ok := <-c.msgs:
|
||
if !ok {
|
||
c.t.Fatalf("连接已关闭但没有收到关闭帧")
|
||
}
|
||
if msg.err != nil {
|
||
if msg.closeCode != want {
|
||
c.t.Fatalf("关闭码应为 %d,得到 %d (%v)", want, msg.closeCode, msg.err)
|
||
}
|
||
return
|
||
}
|
||
case <-timer.C:
|
||
c.t.Fatalf("等待服务端关闭连接超时")
|
||
}
|
||
}
|
||
}
|
||
|
||
func decode[T any](t *testing.T, raw json.RawMessage) T {
|
||
t.Helper()
|
||
var v T
|
||
if err := json.Unmarshal(raw, &v); err != nil {
|
||
t.Fatalf("解析载荷 %s: %v", raw, err)
|
||
}
|
||
return v
|
||
}
|
||
|
||
func insertChange(t *testing.T, db *sql.DB, entity string, entityID int64, action, payload string) int64 {
|
||
t.Helper()
|
||
res, err := db.Exec(
|
||
`INSERT INTO change_log(entity_type, entity_id, action, payload_json) VALUES (?, ?, ?, ?)`,
|
||
entity, entityID, action, payload)
|
||
if err != nil {
|
||
t.Fatalf("写入 change_log: %v", err)
|
||
}
|
||
id, err := res.LastInsertId()
|
||
if err != nil {
|
||
t.Fatalf("读取 change_log id: %v", err)
|
||
}
|
||
return id
|
||
}
|
||
|
||
func loadChanges(t *testing.T, h *Hub, since int64, limit int) []Change {
|
||
t.Helper()
|
||
changes, err := h.LoadChanges(context.Background(), since, limit)
|
||
if err != nil {
|
||
t.Fatalf("读取增量变更: %v", err)
|
||
}
|
||
return changes
|
||
}
|
||
|
||
func waitFor(t *testing.T, what string, cond func() bool) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
if cond() {
|
||
return
|
||
}
|
||
time.Sleep(5 * time.Millisecond)
|
||
}
|
||
t.Fatalf("等待条件超时: %s", what)
|
||
}
|
||
|
||
func ptrInt64(v int64) *int64 { return &v }
|
||
|
||
// ---------- 客户端状态归约(模拟前端按 version 应用变更) ----------
|
||
|
||
type clientState map[string]json.RawMessage
|
||
|
||
func stateKey(ch Change) string { return fmt.Sprintf("%s/%d", ch.Entity, ch.ID) }
|
||
|
||
func applyChanges(s clientState, changes []Change) clientState {
|
||
for _, ch := range changes {
|
||
switch ch.Action {
|
||
case ActionVoid:
|
||
delete(s, stateKey(ch))
|
||
case ActionReplace:
|
||
s[ch.Entity] = ch.Data
|
||
default:
|
||
s[stateKey(ch)] = ch.Data
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
// ---------- 用例 ----------
|
||
|
||
func TestHelloSnapshotThenChange(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
|
||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
if snap.Version != 0 {
|
||
t.Fatalf("空库快照版本应为 0,得到 %d", snap.Version)
|
||
}
|
||
|
||
v := insertChange(t, env.db, "result", 42, ActionUpsert, `{"rank":1}`)
|
||
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
|
||
|
||
got := decode[changePayload](t, c.recvEvent(EventChange))
|
||
if got.Version != v {
|
||
t.Fatalf("change 版本应为 %d,得到 %d", v, got.Version)
|
||
}
|
||
if len(got.Changes) != 1 {
|
||
t.Fatalf("change 条数应为 1,得到 %d", len(got.Changes))
|
||
}
|
||
ch := got.Changes[0]
|
||
if ch.Entity != "result" || ch.ID != 42 || ch.Action != ActionUpsert {
|
||
t.Fatalf("change 内容不符: %+v", ch)
|
||
}
|
||
if string(ch.Data) != `{"rank":1}` {
|
||
t.Fatalf("change data 不符: %s", ch.Data)
|
||
}
|
||
if env.hub.Version() != v {
|
||
t.Fatalf("Hub 版本应推进到 %d,得到 %d", v, env.hub.Version())
|
||
}
|
||
}
|
||
|
||
// 断线期间的变更通过 catchup 补齐,且终态与全量重放的终态一致。
|
||
func TestReconnectCatchupTerminalState(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
v1 := insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
v2 := insertChange(t, env.db, "standings", 0, ActionReplace, `[{"teamId":1,"points":10}]`)
|
||
|
||
c1 := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
snap := decode[Snapshot](t, c1.recvEvent(EventSnapshot))
|
||
if snap.Version != v2 || snap.Version != v1+1 {
|
||
t.Fatalf("首次快照版本应为 %d,得到 %d", v2, snap.Version)
|
||
}
|
||
c1.close()
|
||
|
||
v3 := insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`)
|
||
v4 := insertChange(t, env.db, "result", 1, ActionVoid, `{}`)
|
||
v5 := insertChange(t, env.db, "standings", 0, ActionReplace, `[{"teamId":1,"points":12}]`)
|
||
if v4 != v3+1 || v5 != v4+1 {
|
||
t.Fatalf("change_log 版本应连续递增: %d %d %d", v3, v4, v5)
|
||
}
|
||
|
||
c2 := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(snap.Version)})
|
||
cp := decode[catchupPayload](t, c2.recvEvent(EventCatchup))
|
||
|
||
if cp.FromVersion != snap.Version {
|
||
t.Fatalf("fromVersion 应为 %d,得到 %d", snap.Version, cp.FromVersion)
|
||
}
|
||
if cp.Version != v5 {
|
||
t.Fatalf("catchup 版本应为 %d,得到 %d", v5, cp.Version)
|
||
}
|
||
// 版本号不上线路(协议只在消息层带 version),因此按内容核对补发的三条变更。
|
||
wantChanges := []struct {
|
||
entity string
|
||
id int64
|
||
action string
|
||
}{
|
||
{"result", 2, ActionUpsert},
|
||
{"result", 1, ActionVoid},
|
||
{"standings", 0, ActionReplace},
|
||
}
|
||
if len(cp.Changes) != len(wantChanges) {
|
||
t.Fatalf("catchup 条数应为 %d,得到 %d", len(wantChanges), len(cp.Changes))
|
||
}
|
||
for i, want := range wantChanges {
|
||
got := cp.Changes[i]
|
||
if got.Entity != want.entity || got.ID != want.id || got.Action != want.action {
|
||
t.Fatalf("第 %d 条变更应为 %+v,得到 %+v", i, want, got)
|
||
}
|
||
}
|
||
|
||
// 断线前已应用的变更 + catchup 的变更 == 从头全量重放,终态必须一致。
|
||
base := applyChanges(clientState{}, loadChanges(t, env.hub, 0, int(snap.Version)))
|
||
got := applyChanges(base, cp.Changes)
|
||
want := applyChanges(clientState{}, loadChanges(t, env.hub, 0, 0))
|
||
if !reflect.DeepEqual(got, want) {
|
||
t.Fatalf("catchup 终态与全量重放不一致:\n got=%v\nwant=%v", got, want)
|
||
}
|
||
|
||
// 新连接的整包快照版本必须与 catchup 的终点一致。
|
||
fresh, err := env.hub.Snapshot(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("构建快照: %v", err)
|
||
}
|
||
if fresh.Version != cp.Version {
|
||
t.Fatalf("快照版本 %d 与 catchup 版本 %d 不一致", fresh.Version, cp.Version)
|
||
}
|
||
}
|
||
|
||
// 增量超过阈值时回落全量快照。
|
||
func TestCatchupFallsBackToSnapshotWhenTooOld(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetMaxCatchup(2)
|
||
first := insertChange(t, env.db, "result", 1, ActionUpsert, `{}`)
|
||
for i := int64(2); i <= 5; i++ {
|
||
insertChange(t, env.db, "result", i, ActionUpsert, `{}`)
|
||
}
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(first - 1)})
|
||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
if snap.Version != 5 {
|
||
t.Fatalf("应回落全量快照且版本为 5,得到 %d", snap.Version)
|
||
}
|
||
}
|
||
|
||
func TestDuplicateDeliveryIgnored(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
v1 := insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
changes := loadChanges(t, env.hub, 0, 0)
|
||
env.hub.Broadcast(changes)
|
||
|
||
got := decode[changePayload](t, c.recvEvent(EventChange))
|
||
if got.Version != v1 {
|
||
t.Fatalf("首投版本应为 %d,得到 %d", v1, got.Version)
|
||
}
|
||
c.send(EventAck, ackPayload{Version: v1})
|
||
|
||
// 同一批变更再投一次,客户端不应再收到。
|
||
env.hub.Broadcast(changes)
|
||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||
t.Fatalf("重复投递未被忽略,收到事件 %s", ev)
|
||
}
|
||
|
||
// 新变更仍能正常下发。
|
||
v2 := insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`)
|
||
env.hub.Broadcast(loadChanges(t, env.hub, v1, 0))
|
||
got = decode[changePayload](t, c.recvEvent(EventChange))
|
||
if got.Version != v2 {
|
||
t.Fatalf("新变更版本应为 %d,得到 %d", v2, got.Version)
|
||
}
|
||
}
|
||
|
||
// 心跳携带版本号与服务器时间;版本落后时客户端 resync 能拿到最新快照。
|
||
// 本用例测的是协议本身(请求 → 响应),整库快照窗口另有专门用例,这里关闭限流。
|
||
func TestHeartbeatAndResync(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||
env.hub.SetSnapshotMinInterval(0)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
// 只写库不广播:心跳版本会领先于客户端,客户端据此发起 resync。
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{}`)
|
||
insertChange(t, env.db, "result", 2, ActionUpsert, `{}`)
|
||
|
||
hb := decode[heartbeatPayload](t, c.recvEvent(EventHeartbeat))
|
||
if hb.Version != 2 {
|
||
t.Fatalf("心跳版本应为 2,得到 %d", hb.Version)
|
||
}
|
||
if _, err := time.Parse(time.RFC3339Nano, hb.ServerTime); err != nil {
|
||
t.Fatalf("serverTime 不是 RFC3339: %q (%v)", hb.ServerTime, err)
|
||
}
|
||
|
||
c.send(EventResync, struct{}{})
|
||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
if snap.Version != 2 {
|
||
t.Fatalf("resync 快照版本应为 2,得到 %d", snap.Version)
|
||
}
|
||
}
|
||
|
||
// 大屏实例注册表:instanceId、在线状态、当前页、最后心跳可查。
|
||
// 重复 hello 会回一份快照(窗口内属于限流范围,这里关掉限流以保持用例只关心注册表)。
|
||
func TestDisplayInstancesRegistry(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(0)
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
insts := env.hub.Instances()
|
||
if len(insts) != 1 {
|
||
t.Fatalf("实例数应为 1,得到 %d", len(insts))
|
||
}
|
||
if insts[0].ID != "screen-1" || !insts[0].Online || insts[0].Page != "standings" {
|
||
t.Fatalf("实例注册内容不符: %+v", insts[0])
|
||
}
|
||
if insts[0].LastHeartbeat.IsZero() {
|
||
t.Fatalf("最后心跳不应为零值: %+v", insts[0])
|
||
}
|
||
|
||
// 重复 hello 不应把实例引用计数加两次,否则断开后仍会显示在线。
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"})
|
||
c.recvEvent(EventSnapshot)
|
||
if insts := env.hub.Instances(); len(insts) != 1 || !insts[0].Online {
|
||
t.Fatalf("重复 hello 后实例应仍只有一个且在线: %+v", insts)
|
||
}
|
||
|
||
gotoCmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "medals"})
|
||
if err != nil {
|
||
t.Fatalf("构造 goto: %v", err)
|
||
}
|
||
env.hub.SendCommand(gotoCmd)
|
||
cmd := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand))
|
||
if cmd.Action != CmdGoto {
|
||
t.Fatalf("指令 action 应为 goto,得到 %s", cmd.Action)
|
||
}
|
||
if insts := env.hub.Instances(); insts[0].Page != "medals" {
|
||
t.Fatalf("goto 后当前页应为 medals,得到 %s", insts[0].Page)
|
||
}
|
||
|
||
detail, err := NewDisplayCommand(CmdShowDetail, map[string]any{"eventId": 7})
|
||
if err != nil {
|
||
t.Fatalf("构造 show-detail: %v", err)
|
||
}
|
||
env.hub.SendCommand(detail)
|
||
if got := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand)); got.Action != CmdShowDetail {
|
||
t.Fatalf("指令 action 应为 show-detail,得到 %s", got.Action)
|
||
}
|
||
|
||
c.close()
|
||
waitFor(t, "实例离线", func() bool {
|
||
insts := env.hub.Instances()
|
||
return len(insts) == 1 && !insts[0].Online
|
||
})
|
||
}
|
||
|
||
// 房间隔离:admin:<userId> 只发给对应管理端,且 userId 来自会话而非客户端自报。
|
||
func TestAdminRoomTargeting(t *testing.T) {
|
||
env := newTestEnvAs(t, &auth.User{ID: 7, Username: "boss", Role: auth.RoleAdmin, Active: true})
|
||
display := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
display.recvEvent(EventSnapshot)
|
||
// hello 不再携带 userId:定向房间必须用会话里的 ID。
|
||
admin := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||
admin.recvEvent(EventSnapshot)
|
||
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
env.hub.BroadcastTo(AdminRoom(7), loadChanges(t, env.hub, 0, 0))
|
||
|
||
got := decode[changePayload](t, admin.recvEvent(EventChange))
|
||
if got.Version != 1 {
|
||
t.Fatalf("定向 change 版本应为 1,得到 %d", got.Version)
|
||
}
|
||
if ev, _, ok := display.recvWithin(300 * time.Millisecond); ok {
|
||
t.Fatalf("大屏不应收到 admin:<uid> 房间的消息,收到 %s", ev)
|
||
}
|
||
}
|
||
|
||
// guest 自报 role=admin 必须被拒绝:管理端房间只能由会话授予。
|
||
// 断开必须带上策略性关闭码,否则客户端会当成网络异常无限重连。
|
||
func TestGuestCannotSubscribeAdminRoom(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||
|
||
c.expectClosedWithCode(3*time.Second, websocket.StatusPolicyViolation)
|
||
}
|
||
|
||
// guest 只能订阅 display 房间:即使 hello 里塞了 userId 也不能进入定向房间。
|
||
func TestGuestStaysInDisplayRoom(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
c := env.dial(t, nil)
|
||
c.send(EventHello, map[string]any{"role": RoleDisplay, "userId": 99})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
changes := loadChanges(t, env.hub, 0, 0)
|
||
env.hub.BroadcastTo(RoomAdmin, changes)
|
||
env.hub.BroadcastTo(AdminRoom(99), changes)
|
||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||
t.Fatalf("guest 不应收到管理端房间的消息,收到 %s", ev)
|
||
}
|
||
|
||
// display 房间的消息仍然正常到达。
|
||
env.hub.BroadcastTo(RoomDisplay, changes)
|
||
if got := decode[changePayload](t, c.recvEvent(EventChange)); got.Version != 1 {
|
||
t.Fatalf("大屏应收到 display 房间消息,版本得到 %d", got.Version)
|
||
}
|
||
}
|
||
|
||
// recorder 会话可以进管理端房间(大屏控制/数据同步都由管理端承担)。
|
||
func TestRecorderSessionJoinsAdminRoom(t *testing.T) {
|
||
env := newTestEnvAs(t, &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true})
|
||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
env.hub.BroadcastTo(RoomAdmin, loadChanges(t, env.hub, 0, 0))
|
||
|
||
if got := decode[changePayload](t, c.recvEvent(EventChange)); got.Version != 1 {
|
||
t.Fatalf("recorder 应进入管理端房间,版本得到 %d", got.Version)
|
||
}
|
||
}
|
||
|
||
// 慢客户端:ack 落后超过阈值即被识别并断开。
|
||
func TestSlowClientDetected(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSlowLag(1)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "slow-1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
for i := int64(1); i <= 3; i++ {
|
||
insertChange(t, env.db, "result", i, ActionUpsert, `{}`)
|
||
}
|
||
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
|
||
|
||
waitFor(t, "慢客户端被标记", func() bool {
|
||
insts := env.hub.Instances()
|
||
return len(insts) == 1 && insts[0].Slow
|
||
})
|
||
waitFor(t, "慢客户端连接被断开", func() bool {
|
||
insts := env.hub.Instances()
|
||
return len(insts) == 1 && !insts[0].Online
|
||
})
|
||
// 必须用自定义关闭码:前端把所有 1008 当登录失效并永久停重连,大屏会就此冻结。
|
||
c.expectClosedWithCodeDraining(3*time.Second, slowCloseCode)
|
||
}
|
||
|
||
// 快照对所有角色下发,但系统配置是 admin-only:guest/recorder 只能拿到大屏渲染
|
||
// 所需的 event/display/榜单开关,默认积分模板、审核开关与折算系数必须置空/清零。
|
||
func TestSnapshotSettingsRedactedForNonAdmin(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
user *auth.User
|
||
}{
|
||
{"guest", &auth.User{Role: auth.RoleGuest}},
|
||
{"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env := newTestEnvAs(t, tc.user)
|
||
mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
|
||
mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: tc.name + "-1"})
|
||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
|
||
if snap.Settings.DefaultProfileID != nil {
|
||
t.Fatalf("非 admin 不应看到 defaultProfileId: %+v", snap.Settings.DefaultProfileID)
|
||
}
|
||
if snap.Settings.RequireApproval {
|
||
t.Fatalf("非 admin 不应看到 requireApproval")
|
||
}
|
||
if snap.Settings.Standings.IndividualToTeamWeight != 0 {
|
||
t.Fatalf("非 admin 不应看到 individualToTeamWeight: %v", snap.Settings.Standings.IndividualToTeamWeight)
|
||
}
|
||
// 大屏要用的字段必须保留,裁剪不能误伤标题/主题/榜单开关。
|
||
if snap.Settings.Event.Name == "" || snap.Settings.Display.RotateSecond == 0 {
|
||
t.Fatalf("event/display 不应被裁剪: %+v", snap.Settings)
|
||
}
|
||
if snap.Settings.Standings.RankingMode != "points" ||
|
||
!snap.Settings.Standings.ShowIndividualBoard || !snap.Settings.Standings.ShowIndividualMedals {
|
||
t.Fatalf("standings 展示开关不应被裁剪: %+v", snap.Settings.Standings)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// admin 仍拿到完整配置,管理端才能在 WS 首包里读到默认积分模板与审核开关。
|
||
func TestSnapshotSettingsFullForAdmin(t *testing.T) {
|
||
env := newTestEnvAs(t, &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true})
|
||
mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
|
||
mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
|
||
if snap.Settings.DefaultProfileID == nil {
|
||
t.Fatalf("admin 应看到 defaultProfileId")
|
||
}
|
||
if !snap.Settings.RequireApproval {
|
||
t.Fatalf("admin 应看到 requireApproval")
|
||
}
|
||
if snap.Settings.Standings.IndividualToTeamWeight != 0.5 {
|
||
t.Fatalf("admin 应看到 individualToTeamWeight=0.5,得到 %v", snap.Settings.Standings.IndividualToTeamWeight)
|
||
}
|
||
}
|
||
|
||
// resync 在 hello 之前不可用:否则会绕过房间分配并提前清掉 hello 超时。
|
||
func TestResyncBeforeHelloIgnored(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(200 * time.Millisecond)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
c := env.dial(t, nil)
|
||
c.send(EventResync, struct{}{})
|
||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||
t.Fatalf("hello 之前的 resync 不应下发快照,收到事件 %s", ev)
|
||
}
|
||
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||
t.Fatalf("hello 后应收到版本 1 的快照,得到 %d", snap.Version)
|
||
}
|
||
// hello 之后 resync 仍然可用,只是要与刚发出去的整库快照共用同一个窗口
|
||
// (生产默认窗口与客户端 3s 冷却一致;这里把它调短,免得测试睡 3 秒)。
|
||
time.Sleep(250 * time.Millisecond)
|
||
c.send(EventResync, struct{}{})
|
||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||
t.Fatalf("hello 后 resync 应拿到快照,得到 %d", snap.Version)
|
||
}
|
||
}
|
||
|
||
// 离线实例不能无限堆积:超过 TTL 后在下一次读取注册表时被清理。
|
||
func TestInstanceRegistryPrunesStaleOffline(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "stale-1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
c.close()
|
||
waitFor(t, "实例离线", func() bool {
|
||
insts := env.hub.Instances()
|
||
return len(insts) == 1 && !insts[0].Online
|
||
})
|
||
|
||
env.hub.mu.Lock()
|
||
env.hub.instances["stale-1"].lastHeartbeat = time.Now().Add(-instanceTTL - time.Minute)
|
||
env.hub.mu.Unlock()
|
||
|
||
if insts := env.hub.Instances(); len(insts) != 0 {
|
||
t.Fatalf("过期离线实例应被清理,得到 %+v", insts)
|
||
}
|
||
}
|
||
|
||
// 读写 goroutine 的 panic 必须被兜住并断开该连接,而不是带走进程。
|
||
func TestGuardLoopRecoversPanic(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
c := newClient(env.hub, nil, "test", wsIdentity{Role: RoleDisplay})
|
||
|
||
func() {
|
||
defer c.guardLoop("读")
|
||
panic("boom")
|
||
}()
|
||
|
||
select {
|
||
case <-c.done:
|
||
default:
|
||
t.Fatal("panic 后应关闭该连接")
|
||
}
|
||
if c.closeCode != websocket.StatusInternalError {
|
||
t.Fatalf("panic 后关闭码应为 %d,得到 %d", websocket.StatusInternalError, c.closeCode)
|
||
}
|
||
}
|
||
|
||
// ---------- 按实例定向下发 ----------
|
||
|
||
// instancePage 取注册表里某个实例的当前页;实例不存在返回空串。
|
||
func instancePage(insts []Instance, id string) string {
|
||
for _, st := range insts {
|
||
if st.ID == id {
|
||
return st.Page
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 定向下发只发给被点名的在线实例;注册表里的「当前页」也只更新那一块屏。
|
||
func TestSendCommandTargetsSingleInstance(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a", Page: "standings", Screen: "1"})
|
||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b", Page: "standings", Screen: "2"})
|
||
a.recvEvent(EventSnapshot)
|
||
b.recvEvent(EventSnapshot)
|
||
|
||
cmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "medals"})
|
||
if err != nil {
|
||
t.Fatalf("构造 goto: %v", err)
|
||
}
|
||
matched, err := env.hub.SendCommandToInstances([]string{"screen-a"}, cmd)
|
||
if err != nil {
|
||
t.Fatalf("定向下发: %v", err)
|
||
}
|
||
if matched != 1 {
|
||
t.Fatalf("命中实例数应为 1,得到 %d", matched)
|
||
}
|
||
if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdGoto {
|
||
t.Fatalf("被点名的实例应收到 goto,得到 %s", got.Action)
|
||
}
|
||
// 没被点名的实例一条都不能收到(心跳之外的静默是这条断言的全部意义)。
|
||
if ev, _, ok := b.recvWithin(200 * time.Millisecond); ok {
|
||
t.Fatalf("未被点名的实例不该收到消息,得到 %s", ev)
|
||
}
|
||
|
||
insts := env.hub.Instances()
|
||
if got := instancePage(insts, "screen-a"); got != "medals" {
|
||
t.Fatalf("被点名实例的当前页应更新为 medals,得到 %q", got)
|
||
}
|
||
if got := instancePage(insts, "screen-b"); got != "standings" {
|
||
t.Fatalf("未被点名实例的当前页不该变,得到 %q", got)
|
||
}
|
||
}
|
||
|
||
// targets 为空保持广播语义:所有在线实例都收到。
|
||
func TestSendCommandEmptyTargetsBroadcasts(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"})
|
||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"})
|
||
a.recvEvent(EventSnapshot)
|
||
b.recvEvent(EventSnapshot)
|
||
|
||
cmd, err := NewDisplayCommand(CmdLock, map[string]any{})
|
||
if err != nil {
|
||
t.Fatalf("构造 lock: %v", err)
|
||
}
|
||
matched, err := env.hub.SendCommandToInstances(nil, cmd)
|
||
if err != nil {
|
||
t.Fatalf("广播: %v", err)
|
||
}
|
||
if matched != 2 {
|
||
t.Fatalf("广播应命中 2 个在线实例,得到 %d", matched)
|
||
}
|
||
for _, c := range []*wsClient{a, b} {
|
||
if got := decode[DisplayCommand](t, c.recvEvent(EventDisplayCommand)); got.Action != CmdLock {
|
||
t.Fatalf("广播时每个实例都应收到 lock,得到 %s", got.Action)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 目标全都不在线(已断开或 id 不存在):命中 0,且没有任何连接收到指令。
|
||
func TestSendCommandTargetsOfflineInstances(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"})
|
||
a.recvEvent(EventSnapshot)
|
||
a.close()
|
||
waitFor(t, "实例离线", func() bool {
|
||
insts := env.hub.Instances()
|
||
return len(insts) == 1 && !insts[0].Online
|
||
})
|
||
|
||
cmd, err := NewDisplayCommand(CmdReload, map[string]any{})
|
||
if err != nil {
|
||
t.Fatalf("构造 reload: %v", err)
|
||
}
|
||
matched, err := env.hub.SendCommandToInstances([]string{"screen-a", "never-seen"}, cmd)
|
||
if err != nil {
|
||
t.Fatalf("定向下发: %v", err)
|
||
}
|
||
if matched != 0 {
|
||
t.Fatalf("离线/不存在的实例不该计入命中,得到 %d", matched)
|
||
}
|
||
}
|
||
|
||
// 目标里混了在线与离线:在线的收到,命中数只算在线的。
|
||
func TestSendCommandTargetsMixedOnlineOffline(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a"})
|
||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b"})
|
||
a.recvEvent(EventSnapshot)
|
||
b.recvEvent(EventSnapshot)
|
||
b.close()
|
||
waitFor(t, "screen-b 离线", func() bool {
|
||
for _, st := range env.hub.Instances() {
|
||
if st.ID == "screen-b" {
|
||
return !st.Online
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
|
||
cmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "results"})
|
||
if err != nil {
|
||
t.Fatalf("构造 goto: %v", err)
|
||
}
|
||
matched, err := env.hub.SendCommandToInstances([]string{"screen-a", "screen-b"}, cmd)
|
||
if err != nil {
|
||
t.Fatalf("定向下发: %v", err)
|
||
}
|
||
if matched != 1 {
|
||
t.Fatalf("只应在线的 screen-a 计入命中,得到 %d", matched)
|
||
}
|
||
if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdGoto {
|
||
t.Fatalf("在线的实例应收到 goto,得到 %s", got.Action)
|
||
}
|
||
}
|
||
|
||
// targets 里带空白(管理端从表格/粘贴里带过来的 id)时,匹配与「当前页」写入必须用
|
||
// 同一个归一化后的值:以前匹配走 TrimSpace、写注册表用原值,结果是指令真的发到了那块屏,
|
||
// 注册表里的当前页却停在旧值上,管理端下一次轮询又把界面上刚切过去的页显示回来。
|
||
func TestSendCommandTargetsTrimsWhitespaceForPageRegistry(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-a", Page: "standings"})
|
||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-b", Page: "standings"})
|
||
a.recvEvent(EventSnapshot)
|
||
b.recvEvent(EventSnapshot)
|
||
|
||
cmd, err := NewDisplayCommand(CmdGoto, map[string]string{"page": "medals"})
|
||
if err != nil {
|
||
t.Fatalf("构造 goto: %v", err)
|
||
}
|
||
matched, err := env.hub.SendCommandToInstances([]string{" screen-a \n"}, cmd)
|
||
if err != nil {
|
||
t.Fatalf("定向下发: %v", err)
|
||
}
|
||
if matched != 1 {
|
||
t.Fatalf("带空白的 id 应命中 1 个实例,得到 %d", matched)
|
||
}
|
||
if got := decode[DisplayCommand](t, a.recvEvent(EventDisplayCommand)); got.Action != CmdGoto {
|
||
t.Fatalf("被点名的实例应收到 goto,得到 %s", got.Action)
|
||
}
|
||
|
||
insts := env.hub.Instances()
|
||
if got := instancePage(insts, "screen-a"); got != "medals" {
|
||
t.Fatalf("被点名实例的当前页应更新为 medals,得到 %q", got)
|
||
}
|
||
if got := instancePage(insts, "screen-b"); got != "standings" {
|
||
t.Fatalf("未被点名实例的当前页不该变,得到 %q", got)
|
||
}
|
||
}
|
||
|
||
// 非法 action 走定向通道要报错,不能静默丢掉。
|
||
func TestSendCommandTargetsRejectsUnknownAction(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
if _, err := env.hub.SendCommandToInstances([]string{"screen-a"}, DisplayCommand{Action: "nope"}); err == nil {
|
||
t.Fatal("非法 action 应报错")
|
||
}
|
||
}
|
||
|
||
// 屏号上报:Console 窗口带 screen,局域网浏览器不带(空串),两者都要能查出来。
|
||
func TestDisplayInstancesReportScreen(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
console := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "console-1", Page: "standings", Screen: "2"})
|
||
lan := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "lan-1", Page: "medals"})
|
||
console.recvEvent(EventSnapshot)
|
||
lan.recvEvent(EventSnapshot)
|
||
|
||
byID := make(map[string]Instance)
|
||
for _, st := range env.hub.Instances() {
|
||
byID[st.ID] = st
|
||
}
|
||
if got := byID["console-1"].Screen; got != "2" {
|
||
t.Fatalf("Console 窗口的屏号应为 2,得到 %q", got)
|
||
}
|
||
if got := byID["lan-1"].Screen; got != "" {
|
||
t.Fatalf("局域网浏览器不带屏号,应为空串,得到 %q", got)
|
||
}
|
||
}
|
||
|
||
// sessionCheckerFunc 把桩会话复查器写成普通函数:用例里只需回答「这条会话还在不在」。
|
||
type sessionCheckerFunc func(ctx context.Context, sessionID string) (bool, error)
|
||
|
||
func (f sessionCheckerFunc) SessionActive(ctx context.Context, sessionID string) (bool, error) {
|
||
return f(ctx, sessionID)
|
||
}
|
||
|
||
// ---------- 会话撤销:长连接必须跟着断开 ----------
|
||
|
||
// 会话被撤销(登出 / 改密 / 管理员重置密码 / 停用用户)后,已经建立的 /ws 连接必须被断开,
|
||
// 不能再以原角色继续收推送;管理端连接按 1008 关闭,前端据此停止重连并引导重新登录。
|
||
func TestSessionRevokedClosesAdminConnection(t *testing.T) {
|
||
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-1")
|
||
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
|
||
|
||
var revoked atomic.Bool
|
||
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
|
||
return !revoked.Load(), nil
|
||
}))
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
// 会话仍有效时照常收推送:复查不能误伤在线用户。
|
||
insertChange(t, env.db, "teams", 1, ActionUpsert, `{"id":1}`)
|
||
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
|
||
c.recvEvent(EventChange)
|
||
|
||
// 撤销之后:最迟一个心跳周期内断开,关闭码为「登录失效」。
|
||
revoked.Store(true)
|
||
c.expectClosedWithCodeDraining(3*time.Second, websocket.StatusPolicyViolation)
|
||
|
||
// 断开后这条连接不再是任何广播的投递对象:后续变更推不到它。
|
||
waitFor(t, "失效连接退出", func() bool { return len(env.hub.roomClients("")) == 0 })
|
||
insertChange(t, env.db, "teams", 2, ActionUpsert, `{"id":2}`)
|
||
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
|
||
if n := len(env.hub.roomClients("")); n != 0 {
|
||
t.Fatalf("会话失效后不应再有投递对象,得到 %d 条连接", n)
|
||
}
|
||
}
|
||
|
||
// 大屏连接(role=display 但带会话)同样要断开,但用 1001:客户端会自动重连,
|
||
// 重连时握手按会话失效降级成 guest,现场大屏不该因为操作员登出而永久冻结。
|
||
func TestSessionRevokedClosesDisplayConnectionAsGoingAway(t *testing.T) {
|
||
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-2")
|
||
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
|
||
|
||
var revoked atomic.Bool
|
||
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
|
||
return !revoked.Load(), nil
|
||
}))
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
revoked.Store(true)
|
||
c.expectClosedWithCodeDraining(3*time.Second, websocket.StatusGoingAway)
|
||
}
|
||
|
||
// 没有会话的连接(局域网大屏)不做复查:撤销语义不适用于 guest,也不该白查一次库。
|
||
func TestSessionCheckSkippedWithoutSession(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
|
||
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
|
||
return false, nil // 一旦真被问到就会判死,但 guest 连接根本不该被问
|
||
}))
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "lan-1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); !ok || ev != EventHeartbeat {
|
||
t.Fatalf("guest 连接应继续收心跳,得到 %q ok=%v", ev, ok)
|
||
}
|
||
}
|
||
|
||
// 复查失败(系统库忙 / 被长操作占住)按「仍有效」处理:一次查询抖动不该把大屏整片踢下线。
|
||
func TestSessionCheckFailureKeepsConnection(t *testing.T) {
|
||
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-3")
|
||
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
|
||
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
|
||
return false, errors.New("库忙")
|
||
}))
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||
c.recvEvent(EventSnapshot)
|
||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); !ok || ev != EventHeartbeat {
|
||
t.Fatalf("复查失败时应保留连接并继续心跳,得到 %q ok=%v", ev, ok)
|
||
}
|
||
}
|
||
|
||
// ---------- 增量载荷按角色裁剪 ----------
|
||
|
||
// fullSettingsPayload 是 settings 实体的变更载荷:既有 admin 专属的系统配置,
|
||
// 也有大屏要用的展示字段(公告、标题、榜单开关)。
|
||
const fullSettingsPayload = `{"event":{"name":"测试运动会","subtitle":null,"host":null,"logoPath":null,"primaryColor":null},` +
|
||
`"display":{"background":null,"logo":null,"title":"测试运动会","subtitle":null,"primaryColor":null,` +
|
||
`"rotateSeconds":15,"announce":"请注意检录","fontTitle":null,"fontBody":null,"liveEvents":[],"liveSource":"auto"},` +
|
||
`"standings":{"individualToTeamWeight":0.5,"rankingMode":"points","showIndividualBoard":true,` +
|
||
`"showIndividualMedals":true,"liveOnPublish":true},"defaultProfileId":1,"requireApproval":true}`
|
||
|
||
// assertSettingsPayload 校验一条 settings 载荷是否按身份裁过:
|
||
// 非 admin 不得出现默认积分模板 / 审核开关 / 个人折算系数,但公告与标题这类大屏字段必须保留。
|
||
func assertSettingsPayload(t *testing.T, raw json.RawMessage, wantFull bool) {
|
||
t.Helper()
|
||
var got dto.EventSettings
|
||
if err := json.Unmarshal(raw, &got); err != nil {
|
||
t.Fatalf("settings 载荷无法解析: %s", raw)
|
||
}
|
||
if wantFull {
|
||
if got.DefaultProfileID == nil || !got.RequireApproval || got.Standings.IndividualToTeamWeight != 0.5 {
|
||
t.Fatalf("admin 应拿到完整系统配置: %s", raw)
|
||
}
|
||
} else {
|
||
if got.DefaultProfileID != nil {
|
||
t.Fatalf("非 admin 不应看到 defaultProfileId: %s", raw)
|
||
}
|
||
if got.RequireApproval {
|
||
t.Fatalf("非 admin 不应看到 requireApproval: %s", raw)
|
||
}
|
||
if got.Standings.IndividualToTeamWeight != 0 {
|
||
t.Fatalf("非 admin 不应看到 individualToTeamWeight: %s", raw)
|
||
}
|
||
}
|
||
if got.Event.Name != "测试运动会" || got.Display.Announce == nil || *got.Display.Announce != "请注意检录" {
|
||
t.Fatalf("大屏展示字段不该被裁剪: %s", raw)
|
||
}
|
||
if got.Standings.RankingMode != "points" || !got.Standings.ShowIndividualBoard || !got.Standings.LiveOnPublish {
|
||
t.Fatalf("榜单展示开关不该被裁剪: %s", raw)
|
||
}
|
||
}
|
||
|
||
// 快照裁剪之外,change 与 catchup 两条增量路径也必须按角色裁剪:
|
||
// 否则任何打开 /ws 的浏览器都能从增量里读到系统配置,快照上的裁剪等于被旁路。
|
||
func TestSettingsChangeRedactedByRole(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
user *auth.User
|
||
role string
|
||
full bool
|
||
}{
|
||
{"guest", &auth.User{Role: auth.RoleGuest}, RoleDisplay, false},
|
||
{"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}, RoleAdmin, false},
|
||
{"admin", &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true}, RoleAdmin, true},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env := newTestEnvAs(t, tc.user)
|
||
|
||
// 一条历史变更:用来构造 catchup(重连补齐)。先垫一条无关变更,
|
||
// 让下面的 lastVersion 落在增量区间内(0 会走全量快照)。
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
old := insertChange(t, env.db, entitySettings, 0, ActionUpsert, fullSettingsPayload)
|
||
|
||
// change:在线连接收到一条新的 settings 变更。
|
||
live := env.dial(t, &helloPayload{Role: tc.role, InstanceID: tc.name + "-live"})
|
||
live.recvEvent(EventSnapshot)
|
||
insertChange(t, env.db, entitySettings, 0, ActionUpsert, fullSettingsPayload)
|
||
sent := loadChanges(t, env.hub, old, 0)
|
||
env.hub.Broadcast(sent)
|
||
|
||
got := decode[changePayload](t, live.recvEvent(EventChange))
|
||
if len(got.Changes) != 1 || got.Changes[0].Entity != entitySettings {
|
||
t.Fatalf("应收到一条 settings 变更,得到 %+v", got.Changes)
|
||
}
|
||
assertSettingsPayload(t, got.Changes[0].Data, tc.full)
|
||
// 裁剪必须产出新对象:就地改写共享的 Change 会让 admin 连接也拿到裁剪版。
|
||
if string(sent[0].Data) != fullSettingsPayload {
|
||
t.Fatalf("裁剪不应改写共享的 Change: %s", sent[0].Data)
|
||
}
|
||
|
||
// catchup:重连连接按 lastVersion 补齐,同样不能漏出系统配置。
|
||
catchupClient := env.dial(t, &helloPayload{
|
||
Role: tc.role, InstanceID: tc.name + "-catchup", LastVersion: ptrInt64(old - 1),
|
||
})
|
||
cp := decode[catchupPayload](t, catchupClient.recvEvent(EventCatchup))
|
||
var found bool
|
||
for _, ch := range cp.Changes {
|
||
if ch.Entity != entitySettings {
|
||
continue
|
||
}
|
||
found = true
|
||
assertSettingsPayload(t, ch.Data, tc.full)
|
||
}
|
||
if !found {
|
||
t.Fatalf("catchup 应包含 settings 变更,得到 %+v", cp.Changes)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 载荷不是 EventSettings 形状时宁可只下发空对象,也不能把可能含系统配置的原文透给非 admin。
|
||
func TestSettingsChangeUnknownShapeRedacted(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
insertChange(t, env.db, entitySettings, 0, ActionUpsert, `"旧版扁平配置"`)
|
||
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
|
||
|
||
got := decode[changePayload](t, c.recvEvent(EventChange))
|
||
if string(got.Changes[0].Data) != `{}` {
|
||
t.Fatalf("无法识别的 settings 载荷应降级成空对象,得到 %s", got.Changes[0].Data)
|
||
}
|
||
}
|
||
|
||
// ---------- 整库快照限流 ----------
|
||
//
|
||
// 整库快照有两个入口:resync,和不带 lastVersion 的 hello(首包 / 翻页补的那条)。
|
||
// 两者在 sendInitial 里共用同一个 per-connection 窗口,所以「交替发」绕不过去。
|
||
|
||
// 窗口内连发多次 resync 只出一份整库快照;越过窗口后恢复正常,
|
||
// 客户端「落后了要一次全量」的补齐行为不受影响。
|
||
func TestSnapshotThrottleCoversResync(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(300 * time.Millisecond)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
// 新连接的第一份快照照发,并且它就是本窗口里的那一份。
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||
t.Fatalf("新连接应收到版本 1 的快照,得到 %d", snap.Version)
|
||
}
|
||
for i := 0; i < 5; i++ {
|
||
c.send(EventResync, struct{}{})
|
||
}
|
||
if ev, _, ok := c.recvWithin(150 * time.Millisecond); ok {
|
||
t.Fatalf("同一窗口内不该有第二份整库快照,收到 %s", ev)
|
||
}
|
||
|
||
// 越过窗口后再连发 5 次:只放行一份。
|
||
time.Sleep(350 * time.Millisecond)
|
||
for i := 0; i < 5; i++ {
|
||
c.send(EventResync, struct{}{})
|
||
}
|
||
decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
if ev, _, ok := c.recvWithin(150 * time.Millisecond); ok {
|
||
t.Fatalf("一个窗口只应放行一份整库快照,收到 %s", ev)
|
||
}
|
||
}
|
||
|
||
// 不带 lastVersion 的重复 hello 与 resync 等价:也是要一份整库快照,同样按窗口限流。
|
||
func TestSnapshotThrottleCoversRepeatHello(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(300 * time.Millisecond)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||
t.Fatalf("新连接应收到版本 1 的快照,得到 %d", snap.Version)
|
||
}
|
||
|
||
for i := 0; i < 5; i++ {
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
}
|
||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||
t.Fatalf("窗口内重复 hello 只应回一份快照(首包那份),收到 %s", ev)
|
||
}
|
||
|
||
// 越过窗口后恢复:真实节奏下翻页补的那条 hello 远晚于窗口,照旧生效。
|
||
time.Sleep(350 * time.Millisecond)
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
}
|
||
|
||
// hello 与 resync 交替发也绕不过限流:两者共用一个窗口,而不是各记各的。
|
||
func TestSnapshotThrottleSharedByHelloAndResync(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(time.Minute)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
|
||
resync := func() { c.send(EventResync, struct{}{}) }
|
||
hello := func() { c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"}) }
|
||
resync()
|
||
hello()
|
||
resync()
|
||
hello()
|
||
|
||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||
t.Fatalf("交替 hello / resync 不应绕过整库快照窗口,收到 %s", ev)
|
||
}
|
||
}
|
||
|
||
// 不变式:带 lastVersion 的 hello 走 catchup(增量),与整库快照无关——
|
||
// 刚发过快照也不能把它挡住,而且它不会留下「待发快照」的挂起标记。
|
||
func TestSnapshotThrottleDoesNotAffectCatchup(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
// 窗口与心跳都调短:万一 catchup 请求被误当成「要整库快照」,下面的
|
||
// noSnapshotWithin 立刻就能看见那次多余的补发。
|
||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||
|
||
// 窗口内的第二条 hello 带 lastVersion:必须立刻拿到增量。
|
||
insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`)
|
||
c.send(EventHello, helloPayload{
|
||
Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(snap.Version),
|
||
})
|
||
cp := decode[catchupPayload](t, c.recvEvent(EventCatchup))
|
||
if cp.FromVersion != snap.Version || cp.Version != snap.Version+1 {
|
||
t.Fatalf("catchup 应为 %d → %d,得到 %d → %d",
|
||
snap.Version, snap.Version+1, cp.FromVersion, cp.Version)
|
||
}
|
||
if len(cp.Changes) != 1 || cp.Changes[0].ID != 2 {
|
||
t.Fatalf("catchup 应补上那一条新变更,得到 %+v", cp.Changes)
|
||
}
|
||
// catchup 不是整库快照:不该挂起、也不该在窗口过后补发。
|
||
c.noSnapshotWithin(400 * time.Millisecond)
|
||
}
|
||
|
||
// 不变式:窗口按连接记账。一条连接把自己的窗口占满,不影响别的连接的第一份快照;
|
||
// 断线重连也是新连接(新的 client,窗口为空),第一份快照同样照发。
|
||
func TestSnapshotThrottleIsScopedToConnection(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetSnapshotMinInterval(time.Minute)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
// A 连发 resync 把自己的窗口占满。
|
||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
|
||
a.recvEvent(EventSnapshot)
|
||
for i := 0; i < 3; i++ {
|
||
a.send(EventResync, struct{}{})
|
||
}
|
||
if ev, _, ok := a.recvWithin(200 * time.Millisecond); ok {
|
||
t.Fatalf("A 的窗口内不该再有整库快照,收到 %s", ev)
|
||
}
|
||
|
||
// B 是新连接:第一份快照照发,不受 A 影响。
|
||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "b"})
|
||
if snap := decode[Snapshot](t, b.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||
t.Fatalf("B 作为新连接应立刻拿到快照,得到版本 %d", snap.Version)
|
||
}
|
||
|
||
// 断线重连同样是新连接:现场刷新页面 / 网络抖动都不该被限流影响。
|
||
a.close()
|
||
reconnected := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
|
||
if snap := decode[Snapshot](t, reconnected.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||
t.Fatalf("重连应立刻拿到快照,得到版本 %d", snap.Version)
|
||
}
|
||
}
|
||
|
||
// ---------- 被窗口挡下的整库快照:挂起 + 心跳补发 ----------
|
||
|
||
// 窗口内连发多次 resync:当下不回(否则等于没限流),但请求被挂起,
|
||
// 窗口过后由心跳补一份——大屏不会因为限流静默停在旧数据上。
|
||
func TestPendingSnapshotFlushedByHeartbeat(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.recvEvent(EventSnapshot) // 首包即时发,窗口从这一份开始记账
|
||
|
||
for i := 0; i < 3; i++ {
|
||
c.send(EventResync, struct{}{})
|
||
}
|
||
c.noSnapshotWithin(100 * time.Millisecond)
|
||
|
||
if !c.awaitSnapshot(2 * time.Second) {
|
||
t.Fatal("窗口过后应由心跳补发一份整库快照")
|
||
}
|
||
// 补发即清标记:之后的心跳不再重复发(否则限流就退化成心跳频率了)。
|
||
c.noSnapshotWithin(300 * time.Millisecond)
|
||
}
|
||
|
||
// 不带 lastVersion 的重复 hello 与 resync 走同一条挂起路径:也只补一份。
|
||
func TestPendingSnapshotFromRepeatHelloFlushedOnce(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
for i := 0; i < 5; i++ {
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
}
|
||
c.noSnapshotWithin(100 * time.Millisecond)
|
||
|
||
if !c.awaitSnapshot(2 * time.Second) {
|
||
t.Fatal("窗口过后应由心跳补发一份整库快照")
|
||
}
|
||
c.noSnapshotWithin(300 * time.Millisecond)
|
||
}
|
||
|
||
// hello 与 resync 交替触发时共用一个挂起标记:补发仍然只有一份。
|
||
func TestPendingSnapshotSharedByHelloAndResync(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
for i := 0; i < 3; i++ {
|
||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.send(EventResync, struct{}{})
|
||
}
|
||
c.noSnapshotWithin(100 * time.Millisecond)
|
||
|
||
if !c.awaitSnapshot(2 * time.Second) {
|
||
t.Fatal("窗口过后应由心跳补发一份整库快照")
|
||
}
|
||
c.noSnapshotWithin(300 * time.Millisecond)
|
||
}
|
||
|
||
// 补发的量级仍然是「每窗口一份」:两条连接各自挂起,各自只补一份。
|
||
func TestPendingSnapshotFlushIsPerConnection(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||
|
||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
|
||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "b"})
|
||
a.recvEvent(EventSnapshot)
|
||
b.recvEvent(EventSnapshot)
|
||
|
||
a.send(EventResync, struct{}{})
|
||
b.send(EventResync, struct{}{})
|
||
// 只对 A 做一次「当下不回」的断言:两条连接的静默窗口不能串行等待,
|
||
// 后一条会把自己的补发也等进去(B 的那份本来就该在窗口过后到)。
|
||
a.noSnapshotWithin(100 * time.Millisecond)
|
||
|
||
if !a.awaitSnapshot(2*time.Second) || !b.awaitSnapshot(2*time.Second) {
|
||
t.Fatal("两条挂起的连接都应在窗口过后各补一份快照")
|
||
}
|
||
a.noSnapshotWithin(300 * time.Millisecond)
|
||
b.noSnapshotWithin(300 * time.Millisecond)
|
||
}
|
||
|
||
// ---------- 在线判定 / 握手期限 / 恢复窗口降级 ----------
|
||
|
||
// syncBuffer 是带锁的日志缓冲:日志由实时层的多条 goroutine 写、用例在主 goroutine 读。
|
||
type syncBuffer struct {
|
||
mu sync.Mutex
|
||
buf bytes.Buffer
|
||
}
|
||
|
||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return b.buf.Write(p)
|
||
}
|
||
|
||
func (b *syncBuffer) String() string {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
return b.buf.String()
|
||
}
|
||
|
||
// tune 直接改内部参数:这些值只影响超时,用例需要的是毫秒级跑完,
|
||
// 而 Set* 那一族是留给现场调优的(且本包不该再为测试多开一个导出 API)。
|
||
func (e *testEnv) tune(fn func(h *Hub)) {
|
||
e.hub.mu.Lock()
|
||
defer e.hub.mu.Unlock()
|
||
fn(e.hub)
|
||
}
|
||
|
||
// waitForWithin 与 waitFor 相同,只是等待窗口由调用方给:有的用例要等一次完整的
|
||
// 服务端关闭握手(serve 里最多 2s)。
|
||
func waitForWithin(t *testing.T, what string, d time.Duration, cond func() bool) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(d)
|
||
for time.Now().Before(deadline) {
|
||
if cond() {
|
||
return
|
||
}
|
||
time.Sleep(5 * time.Millisecond)
|
||
}
|
||
t.Fatalf("等待条件超时: %s", what)
|
||
}
|
||
|
||
func instanceOnline(insts []Instance, id string) bool {
|
||
for _, st := range insts {
|
||
if st.ID == id {
|
||
return st.Online
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// trySend 发送一帧并在失败时返回错误:用例要靠它判断「服务端已经关掉这条连接」
|
||
// (普通 send 会直接 Fatal,那条路径在这里是预期结果而不是失败)。
|
||
func (c *wsClient) trySend(event string, data any) error {
|
||
msg, err := encode(event, data)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ctx, cancel := context.WithTimeout(c.ctx, 2*time.Second)
|
||
defer cancel()
|
||
return c.conn.Write(ctx, websocket.MessageText, msg)
|
||
}
|
||
|
||
// muteConn 是一条「连上就不再读」的客户端:它的 TCP 连接还在,但永远不会回应 pong——
|
||
// 这正是拔网线 / 断电 / 机器休眠之后,服务端那一侧看到的样子。
|
||
type muteConn struct {
|
||
t *testing.T
|
||
ctx context.Context
|
||
conn *websocket.Conn
|
||
}
|
||
|
||
func dialMute(t *testing.T, env *testEnv, hello helloPayload) *muteConn {
|
||
t.Helper()
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(env.srv.URL, "http")+"/ws", nil)
|
||
if err != nil {
|
||
cancel()
|
||
t.Fatalf("连接 /ws: %v", err)
|
||
}
|
||
t.Cleanup(func() {
|
||
_ = conn.CloseNow()
|
||
cancel()
|
||
})
|
||
|
||
c := &muteConn{t: t, ctx: ctx, conn: conn}
|
||
if err := c.send(EventHello, hello); err != nil {
|
||
t.Fatalf("发送 hello: %v", err)
|
||
}
|
||
return c
|
||
}
|
||
|
||
func (c *muteConn) send(event string, data any) error {
|
||
msg, err := encode(event, data)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
|
||
defer cancel()
|
||
return c.conn.Write(ctx, websocket.MessageText, msg)
|
||
}
|
||
|
||
// 「在线」必须反映真的还活着:连接对象还在、但对端已经不应答的屏不能一直算命中。
|
||
// 判据是真实的 ping/pong 往返,不是服务端自己刷的时间戳。
|
||
func TestInstanceGoesOfflineWhenPeerStopsAnswering(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.tune(func(h *Hub) {
|
||
h.livenessInterval = 80 * time.Millisecond
|
||
h.pongTimeout = 60 * time.Millisecond
|
||
})
|
||
|
||
// 失联的屏:握手正常,之后一个字节都不回。
|
||
dialMute(t, env, helloPayload{Role: RoleDisplay, InstanceID: "mute-1", Page: "standings"})
|
||
waitFor(t, "失联实例登记在线", func() bool {
|
||
insts := env.hub.Instances()
|
||
return len(insts) == 1 && insts[0].Online
|
||
})
|
||
|
||
// 正常的大屏同时挂着:它一直在收(于是自动回 pong),绝不能被误判成离线。
|
||
alive := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "alive-1", Page: "standings"})
|
||
alive.recvEvent(EventSnapshot)
|
||
|
||
waitForWithin(t, "失联实例被判离线", 5*time.Second, func() bool {
|
||
return !instanceOnline(env.hub.Instances(), "mute-1")
|
||
})
|
||
if !instanceOnline(env.hub.Instances(), "alive-1") {
|
||
t.Fatal("正常应答的实例不该被判离线")
|
||
}
|
||
|
||
cmd, err := NewDisplayCommand(CmdReload, nil)
|
||
if err != nil {
|
||
t.Fatalf("构造 reload: %v", err)
|
||
}
|
||
if matched, err := env.hub.SendCommandToInstances([]string{"mute-1"}, cmd); err != nil || matched != 0 {
|
||
t.Fatalf("失联实例不该再计入命中,得到 %d err=%v", matched, err)
|
||
}
|
||
// 在线的屏不受影响:它的连接与服务端之间一切照旧。
|
||
if ev, _, ok := alive.recvWithin(200 * time.Millisecond); ok {
|
||
t.Fatalf("正常实例不该收到额外消息,得到 %q", ev)
|
||
}
|
||
}
|
||
|
||
// 握手超时是**绝对** deadline:以前每轮 Read 都重开一个 helloTimeout 窗口,
|
||
// 没握手的连接只要在窗口内随便发一帧就能无限期挂着(脚本重连能堆出一片空连接)。
|
||
func TestHelloDeadlineIsAbsolute(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.tune(func(h *Hub) { h.helloTO = 300 * time.Millisecond })
|
||
|
||
// 不 hello,只反复喂合法但无关的帧(ack)。
|
||
c := env.dial(t, nil)
|
||
|
||
closed := false
|
||
deadline := time.Now().Add(1500 * time.Millisecond)
|
||
for time.Now().Before(deadline) && !closed {
|
||
select {
|
||
case msg, ok := <-c.msgs:
|
||
if !ok || msg.err != nil {
|
||
closed = true
|
||
}
|
||
case <-time.After(60 * time.Millisecond):
|
||
// 写失败说明服务端已经关了这条连接,同样算「已断开」。
|
||
closed = c.trySend(EventAck, ackPayload{}) != nil
|
||
}
|
||
}
|
||
if !closed {
|
||
t.Fatal("未握手的连接必须在绝对 deadline(300ms)后被断开,而不是每收一帧就重置计时")
|
||
}
|
||
}
|
||
|
||
// 心跳循环不能替客户端盖「最后活跃」的章:一块不发任何上行帧的大屏,它的
|
||
// LastHeartbeat 必须停在真实收到帧的那一刻,否则注册表里的「在线/最后活跃」
|
||
// 全是服务端自己说的,拔了网线的屏也会显示成刚刚活跃(这正是 A24 的根因)。
|
||
func TestHeartbeatDoesNotRefreshActivityByItself(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.hub.SetHeartbeatInterval(30 * time.Millisecond)
|
||
// 关掉存活探测:这条用例只验证「时间戳的口径」,探测本身另有用例。
|
||
env.tune(func(h *Hub) { h.livenessInterval = 0 })
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
insts := env.hub.Instances()
|
||
if len(insts) != 1 {
|
||
t.Fatalf("实例数应为 1,得到 %d", len(insts))
|
||
}
|
||
before := insts[0].LastHeartbeat
|
||
|
||
// 连收 5 个心跳,期间客户端一个上行帧都不发(它只读)。
|
||
for i := 0; i < 5; i++ {
|
||
c.recvEvent(EventHeartbeat)
|
||
}
|
||
if got := env.hub.Instances()[0].LastHeartbeat; !got.Equal(before) {
|
||
t.Fatalf("服务端自己发心跳不能把「最后活跃」往前推:%v → %v", before, got)
|
||
}
|
||
}
|
||
|
||
// 恢复窗口(库已经关掉、文件还没换完)里实时层必须安静下来:不刷 WARN/ERROR,
|
||
// 也不拿 1011 把刚连上或正在重连的屏踢回去——现场会把那读成「恢复把系统搞坏了」。
|
||
// 降级口径与 httpapi/realtime.go 的 dbClosed 一致:只记 Debug,连接留着、心跳照发。
|
||
func TestRecoveryWindowDegradesQuietly(t *testing.T) {
|
||
logs := &syncBuffer{}
|
||
env := newTestEnvFull(t, &auth.User{Role: auth.RoleGuest}, "", logs)
|
||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||
env.hub.SetSnapshotMinInterval(0)
|
||
|
||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||
c.recvEvent(EventSnapshot)
|
||
|
||
// 模拟恢复动作的中间状态:目标库被关掉(换文件前的一刻)。
|
||
if err := env.db.Close(); err != nil {
|
||
t.Fatalf("关闭赛事库: %v", err)
|
||
}
|
||
|
||
// 大屏想补数据(resync):这条路径在恢复窗口里必然读不到库。
|
||
c.send(EventResync, struct{}{})
|
||
// 连接必须留着,心跳照常下发(被 1011 打回时 recvWithin 会直接 Fatal)。
|
||
if ev, _, ok := c.recvWithin(2 * time.Second); !ok || ev != EventHeartbeat {
|
||
t.Fatalf("恢复窗口里已连接的屏应保持在线并继续收心跳,得到 %q ok=%v", ev, ok)
|
||
}
|
||
// 恢复窗口里新连上的屏同理:首包拿不到也不能被踢。
|
||
fresh := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d2"})
|
||
if ev, _, ok := fresh.recvWithin(2 * time.Second); !ok || ev != EventHeartbeat {
|
||
t.Fatalf("恢复窗口里新连接应保持并收到心跳,得到 %q ok=%v", ev, ok)
|
||
}
|
||
|
||
got := logs.String()
|
||
if strings.Contains(got, "level=ERROR") || strings.Contains(got, "level=WARN") {
|
||
t.Fatalf("恢复窗口里不该出现 WARN/ERROR 日志:\n%s", got)
|
||
}
|
||
if !strings.Contains(got, "库已关闭") {
|
||
t.Fatalf("恢复窗口应留下 Debug 级的降级记录(否则真出问题时无从查起):\n%s", got)
|
||
}
|
||
}
|