301 lines
8.7 KiB
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.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
|
|
}
|