#32: SDK ↔ Go engine I/O sözleşmesi doğrulama harness'i
internal/httpserver/contract_test.go: gerçek engine'i (hub+services+HTTP) gerçek WebSocket üzerinden ayağa kaldırıp SDK'nın gönderdiği WSTS frame'lerini birebir konuşan entegrasyon testleri. Tarayıcısız uçtan-uca kanıt. Kapsanan akışlar: my/socketid, create-room, joinroom, request/pair+accept/pair sinyalleri, pack/to relay (SendOnly), request/to→response/to round-trip, saQut heartbeat ping/pong. go test -race yeşil. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
63680fac19
commit
945b7621a4
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue