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 }