diff --git a/internal/httpserver/contract_test.go b/internal/httpserver/contract_test.go new file mode 100644 index 0000000..7e7a11e --- /dev/null +++ b/internal/httpserver/contract_test.go @@ -0,0 +1,300 @@ +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.ScriptDir = 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 +}