MWSE/internal/httpserver/contract_test.go

301 lines
8.7 KiB
Go

package httpserver
import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
"git.saqut.com/saqut/mwse/internal/config"
"git.saqut.com/saqut/mwse/internal/services"
"git.saqut.com/saqut/mwse/internal/ws"
)
// This file is the #32 acceptance harness: it boots the *real* engine (hub +
// services + HTTP surface) over a *real* WebSocket and speaks the exact WSTS
// frames the TypeScript SDK in ./frontend emits, asserting the replies match the
// shapes the SDK destructures. It is the browser-free proof that the frozen I/O
// contract holds end-to-end against the Go engine.
// testEngine starts the full HTTP handler on an httptest server and returns its
// ws:// URL plus a cleanup. PingInterval is shortened so the heartbeat test does
// not wait the production 10s.
func testEngine(t *testing.T) string {
t.Helper()
hub := ws.NewHub()
services.Register(hub)
cfg := config.Load()
cfg.Conn.PingInterval = 80 * time.Millisecond
cfg.SDKDir = t.TempDir() // static routes are irrelevant here
cfg.PublicDir = t.TempDir()
srv := httptest.NewServer(New(hub, cfg).Handler)
t.Cleanup(srv.Close)
return "ws" + strings.TrimPrefix(srv.URL, "http")
}
// sdkConn is a minimal SDK-shaped WebSocket client speaking WSTS frames.
type sdkConn struct {
t *testing.T
conn *websocket.Conn
ping chan string // payloads of received server pings
}
func dial(t *testing.T, url string) *sdkConn {
t.Helper()
c, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
s := &sdkConn{t: t, conn: c, ping: make(chan string, 8)}
// The SDK answers the server's "saQut" ping with a matching pong (gorilla's
// default does exactly this); we also record the payload so the heartbeat test
// can assert on it.
c.SetPingHandler(func(appData string) error {
select {
case s.ping <- appData:
default:
}
return c.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(time.Second))
})
t.Cleanup(func() { c.Close() })
return s
}
// sendRequest mirrors WSTSProtocol.SendRequest: [pack, id, "R"].
func (s *sdkConn) sendRequest(pack map[string]any, id int) {
s.write([]any{pack, id, "R"})
}
// sendOnly mirrors WSTSProtocol.SendOnly: [pack, "R"] (string in the id slot).
func (s *sdkConn) sendOnly(pack map[string]any) {
s.write([]any{pack, "R"})
}
func (s *sdkConn) write(frame any) {
s.t.Helper()
if err := s.conn.WriteJSON(frame); err != nil {
s.t.Fatalf("write: %v", err)
}
}
// readReply reads frames until it finds a frame correlated to request id. This
// matches the SDK's PackAnalyze, which resolves a pending request whenever the id
// slot is the matching number — whether the frame is a 3-element reply
// [payload, id, "E"] (a direct handler answer) or a 2-element frame
// [payload, id] (an out-of-band answer such as response/to). Server signals
// (string id slot) seen along the way are skipped.
func (s *sdkConn) readReply(id int) (any, string) {
s.t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
_ = s.conn.SetReadDeadline(deadline)
var arr []any
if err := s.conn.ReadJSON(&arr); err != nil {
s.t.Fatalf("read reply for id %d: %v", id, err)
}
if len(arr) >= 2 {
if n, ok := arr[1].(float64); ok && int(n) == id {
flag := ""
if len(arr) >= 3 {
flag, _ = arr[2].(string)
}
return arr[0], flag
}
}
}
}
// readSignal reads frames until it finds a [payload, name] signal (string id
// slot, no third element) matching name.
func (s *sdkConn) readSignal(name string) map[string]any {
s.t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
_ = s.conn.SetReadDeadline(deadline)
var arr []any
if err := s.conn.ReadJSON(&arr); err != nil {
s.t.Fatalf("read signal %q: %v", name, err)
}
if len(arr) == 2 {
if s2, ok := arr[1].(string); ok && s2 == name {
payload, _ := arr[0].(map[string]any)
return payload
}
}
}
}
func TestContractMySocketID(t *testing.T) {
url := testEngine(t)
c := dial(t, url)
// The SDK's Peer.metadata() does exactly this to learn its own id.
c.sendRequest(map[string]any{"type": "my/socketid"}, 1)
payload, flag := c.readReply(1)
if flag != "E" {
t.Fatalf("reply flag = %q, want E", flag)
}
if _, ok := payload.(string); !ok {
t.Fatalf("my/socketid payload = %T, want string id", payload)
}
}
func TestContractCreateAndJoinRoom(t *testing.T) {
url := testEngine(t)
a := dial(t, url)
b := dial(t, url)
// Room.createRoom payload shape.
a.sendRequest(map[string]any{
"type": "create-room",
"accessType": "public",
"joinType": "free",
"notifyActionInvite": false,
"notifyActionJoined": true,
"notifyActionEjected": true,
"description": "demo",
"name": "lobby",
}, 10)
res, _ := a.readReply(10)
resm, _ := res.(map[string]any)
if resm["status"] != "success" {
t.Fatalf("create-room = %v", res)
}
room, _ := resm["room"].(map[string]any)
if room["id"] == nil {
t.Fatalf("create-room reply missing room.id: %v", res)
}
// Room.join payload shape.
b.sendRequest(map[string]any{"type": "joinroom", "name": "lobby", "autoFetchInfo": false}, 11)
jres, _ := b.readReply(11)
if m, _ := jres.(map[string]any); m["status"] != "success" {
t.Fatalf("joinroom = %v", jres)
}
}
func TestContractPairingSignals(t *testing.T) {
url := testEngine(t)
a := dial(t, url)
b := dial(t, url)
aID := mustID(t, a)
bID := mustID(t, b)
// a: requestPair(b)
a.sendRequest(map[string]any{"type": "request/pair", "to": bID}, 20)
if m := a.replyMap(20); m["status"] != "success" {
t.Fatalf("request/pair = %v", m)
}
// b receives a request/pair signal carrying a's id.
if sig := b.readSignal("request/pair"); sig["from"] != aID {
t.Fatalf("request/pair signal from = %v, want %s", sig["from"], aID)
}
// b: acceptPair(a)
b.sendRequest(map[string]any{"type": "accept/pair", "to": aID}, 21)
if m := b.replyMap(21); m["status"] != "success" {
t.Fatalf("accept/pair = %v", m)
}
// a receives accepted/pair.
if sig := a.readSignal("accepted/pair"); sig["from"] != bID {
t.Fatalf("accepted/pair from = %v, want %s", sig["from"], bID)
}
}
func TestContractPackToRelay(t *testing.T) {
url := testEngine(t)
a := dial(t, url)
b := dial(t, url)
_ = mustID(t, a)
bID := mustID(t, b)
// Peer.send WOM path: SendOnly pack/to (no reply expected).
a.sendOnly(map[string]any{"type": "pack/to", "to": bID, "pack": map[string]any{"text": "hello"}})
sig := b.readSignal("pack")
pack, _ := sig["pack"].(map[string]any)
if pack["text"] != "hello" {
t.Fatalf("relayed pack = %v", sig)
}
}
func TestContractRequestResponseOverWire(t *testing.T) {
url := testEngine(t)
a := dial(t, url)
b := dial(t, url)
aID := mustID(t, a)
bID := mustID(t, b)
// a: mwse.request(b, {q:"ping"}) -> request/to with a numeric id, answered
// out-of-band by b's response/to. The engine must NOT pre-answer id 30.
a.sendRequest(map[string]any{"type": "request/to", "to": bID, "pack": map[string]any{"q": "ping"}}, 30)
req := b.readSignal("request")
if req["from"] != aID {
t.Fatalf("request signal from = %v, want %s", req["from"], aID)
}
// b: mwse.response(a, 30, {a:"pong"}) -> response/to (SendOnly) reusing id 30.
b.sendOnly(map[string]any{"type": "response/to", "to": aID, "id": 30, "pack": map[string]any{"a": "pong"}})
// a's pending request id 30 resolves with {from:b, pack:{a:"pong"}}.
ans := a.replyMap(30)
if ans["from"] != bID {
t.Fatalf("answer from = %v, want %s", ans["from"], bID)
}
if p, _ := ans["pack"].(map[string]any); p["a"] != "pong" {
t.Fatalf("answer pack = %v", ans)
}
}
func TestContractHeartbeat(t *testing.T) {
url := testEngine(t)
c := dial(t, url)
// Drive the read pump so control frames (pings) are processed.
go func() {
for {
if _, _, err := c.conn.ReadMessage(); err != nil {
return
}
}
}()
select {
case payload := <-c.ping:
if payload != "saQut" {
t.Fatalf("server ping payload = %q, want saQut", payload)
}
case <-time.After(2 * time.Second):
t.Fatal("no heartbeat ping received within 2s")
}
}
// ---- small helpers -------------------------------------------------------
// replyMap reads the reply for id and asserts it is an object.
func (s *sdkConn) replyMap(id int) map[string]any {
s.t.Helper()
payload, _ := s.readReply(id)
m, ok := payload.(map[string]any)
if !ok {
s.t.Fatalf("reply for id %d = %T, want object", id, payload)
}
return m
}
// mustID learns the connection's own id the way the SDK's Peer.metadata() does.
func mustID(t *testing.T, c *sdkConn) string {
t.Helper()
id := 9000 + int(time.Now().UnixNano()%1000)
c.sendRequest(map[string]any{"type": "my/socketid"}, id)
payload, _ := c.readReply(id)
s, ok := payload.(string)
if !ok {
t.Fatalf("my/socketid payload = %T", payload)
}
return s
}