Files
Scoreboard/server/internal/realtime/hub_test.go
T
AskaEth bc8b1ee6c0 feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
2026-09-09 19:02:24 +08:00

503 lines
15 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 realtime
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/coder/websocket"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// ---------- 测试脚手架 ----------
type testEnv struct {
hub *Hub
db *sql.DB
srv *httptest.Server
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
db := newTestDB(t)
h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})))
srv := httptest.NewServer(h.Handler())
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
}
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}
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
}
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)
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、在线状态、当前页、最后心跳可查。
func TestDisplayInstancesRegistry(t *testing.T) {
env := newTestEnv(t)
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> 只发给对应管理端。
func TestAdminRoomTargeting(t *testing.T) {
env := newTestEnv(t)
display := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
display.recvEvent(EventSnapshot)
admin := env.dial(t, &helloPayload{Role: RoleAdmin, UserID: 7})
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)
}
}
// 慢客户端: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
})
}