2026-05-20 11:34:37 +00:00
|
|
|
package gateway
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-20 11:47:11 +00:00
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync/atomic"
|
2026-05-20 11:34:37 +00:00
|
|
|
"testing"
|
2026-05-20 11:47:11 +00:00
|
|
|
"time"
|
2026-05-20 11:34:37 +00:00
|
|
|
|
2026-05-20 21:42:31 +00:00
|
|
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
2026-05-20 11:34:37 +00:00
|
|
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
2026-05-20 11:47:11 +00:00
|
|
|
|
|
|
|
|
"github.com/gorilla/websocket"
|
2026-05-20 11:34:37 +00:00
|
|
|
)
|
|
|
|
|
|
2026-05-20 11:47:11 +00:00
|
|
|
// ── Mock WebSocket server helper ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// newTestWSServer creates an httptest.Server that upgrades to WebSocket and
|
|
|
|
|
// delegates each connection to handler. The server URL can be converted to
|
|
|
|
|
// a ws:// URL by replacing "http" with "ws".
|
|
|
|
|
func newTestWSServer(t *testing.T, handler func(conn *websocket.Conn)) *httptest.Server {
|
|
|
|
|
t.Helper()
|
|
|
|
|
upgrader := websocket.Upgrader{
|
|
|
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
|
|
|
}
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
handler(conn)
|
|
|
|
|
}))
|
|
|
|
|
return srv
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// wsURL converts an httptest.Server http URL to a ws URL.
|
|
|
|
|
func wsURL(srv *httptest.Server) string {
|
|
|
|
|
return "ws" + strings.TrimPrefix(srv.URL, "http")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Handshake helper for mock server ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// handleHandshake performs the server side of the v3 handshake:
|
|
|
|
|
// 1. Send connect.challenge
|
|
|
|
|
// 2. Read connect request
|
|
|
|
|
// 3. Send hello-ok response
|
|
|
|
|
//
|
|
|
|
|
// Returns the connect request frame for inspection.
|
|
|
|
|
func handleHandshake(t *testing.T, conn *websocket.Conn) map[string]any {
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
|
|
// 1. Send connect.challenge
|
|
|
|
|
challenge := map[string]any{
|
|
|
|
|
"type": "event",
|
|
|
|
|
"event": "connect.challenge",
|
|
|
|
|
"params": map[string]any{"nonce": "test-nonce", "ts": 1716180000000},
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(challenge); err != nil {
|
|
|
|
|
t.Fatalf("server: write challenge: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Read connect request
|
|
|
|
|
var req map[string]any
|
|
|
|
|
if err := conn.ReadJSON(&req); err != nil {
|
|
|
|
|
t.Fatalf("server: read connect request: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if req["method"] != "connect" {
|
|
|
|
|
t.Fatalf("server: expected method=connect, got %v", req["method"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Send hello-ok response
|
|
|
|
|
// Note: helloOKResponse expects ConnID at the top level of the result,
|
|
|
|
|
// matching the WSClient's JSON struct tags.
|
|
|
|
|
result := map[string]any{
|
|
|
|
|
"type": "hello-ok",
|
|
|
|
|
"protocol": 3,
|
|
|
|
|
"connId": "test-conn-123",
|
|
|
|
|
"features": map[string]any{"methods": []string{}, "events": []string{}},
|
|
|
|
|
"auth": map[string]any{"role": "operator", "scopes": []string{"operator.read"}},
|
|
|
|
|
}
|
|
|
|
|
res := map[string]any{
|
|
|
|
|
"type": "res",
|
|
|
|
|
"id": req["id"],
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": result,
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(res); err != nil {
|
|
|
|
|
t.Fatalf("server: write hello-ok: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return req
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// keepAlive reads frames from the connection until an error occurs
|
|
|
|
|
// (e.g., the client disconnects). Used as the default "do nothing"
|
|
|
|
|
// server loop after handshake.
|
|
|
|
|
func keepAlive(conn *websocket.Conn) {
|
|
|
|
|
for {
|
|
|
|
|
var m map[string]any
|
|
|
|
|
if err := conn.ReadJSON(&m); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 1. Test: Full handshake ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestWSClient_Handshake(t *testing.T) {
|
|
|
|
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
|
|
|
|
handleHandshake(t, conn)
|
|
|
|
|
keepAlive(conn)
|
|
|
|
|
})
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
client.Start(ctx)
|
|
|
|
|
close(done)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Wait briefly for handshake to complete
|
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
// Verify connId was set
|
|
|
|
|
client.connMu.Lock()
|
|
|
|
|
connID := client.connId
|
|
|
|
|
client.connMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if connID != "test-conn-123" {
|
|
|
|
|
t.Errorf("expected connId 'test-conn-123', got %q", connID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
|
select {
|
|
|
|
|
case <-done:
|
|
|
|
|
// Client exited cleanly
|
|
|
|
|
case <-time.After(3 * time.Second):
|
|
|
|
|
t.Fatal("WSClient did not shut down after context cancellation")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 2. Test: Send() with response matching ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestWSClient_Send(t *testing.T) {
|
|
|
|
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
|
|
|
|
handleHandshake(t, conn)
|
|
|
|
|
|
|
|
|
|
// Read RPC requests and respond to each
|
|
|
|
|
for {
|
|
|
|
|
var req map[string]any
|
|
|
|
|
if err := conn.ReadJSON(&req); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
reqID, _ := req["id"].(string)
|
|
|
|
|
method, _ := req["method"].(string)
|
|
|
|
|
|
|
|
|
|
var result any
|
|
|
|
|
switch method {
|
|
|
|
|
case "agents.list":
|
|
|
|
|
result = map[string]any{
|
|
|
|
|
"agents": []map[string]any{
|
|
|
|
|
{"id": "otto", "name": "Otto"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
result = map[string]any{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res := map[string]any{
|
|
|
|
|
"type": "res",
|
|
|
|
|
"id": reqID,
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": result,
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(res); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
go client.Start(ctx)
|
|
|
|
|
|
|
|
|
|
// Give the client time to complete handshake
|
|
|
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
resp, err := client.Send("agents.list", nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("Send() returned error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify the response payload
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(resp, &result); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
agents, ok := result["agents"].([]any)
|
|
|
|
|
if !ok || len(agents) != 1 {
|
|
|
|
|
t.Errorf("expected 1 agent in response, got %v", result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 3. Test: Event handler routing ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestWSClient_EventRouting(t *testing.T) {
|
|
|
|
|
eventReceived := make(chan json.RawMessage, 1)
|
|
|
|
|
|
|
|
|
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
|
|
|
|
handleHandshake(t, conn)
|
|
|
|
|
|
|
|
|
|
// After handshake, send a test event
|
|
|
|
|
evt := map[string]any{
|
|
|
|
|
"type": "event",
|
|
|
|
|
"event": "test.event",
|
|
|
|
|
"params": map[string]any{"greeting": "hello from server"},
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(evt); err != nil {
|
|
|
|
|
t.Logf("server: write event: %v", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
keepAlive(conn)
|
|
|
|
|
})
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
|
|
|
|
|
|
|
|
|
// Register event handler BEFORE starting the client
|
|
|
|
|
client.OnEvent("test.event", func(payload json.RawMessage) {
|
|
|
|
|
eventReceived <- payload
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
go client.Start(ctx)
|
|
|
|
|
|
|
|
|
|
// Wait for the event handler to fire
|
|
|
|
|
select {
|
|
|
|
|
case payload := <-eventReceived:
|
|
|
|
|
var data map[string]any
|
|
|
|
|
if err := json.Unmarshal(payload, &data); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal event payload: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if greeting, _ := data["greeting"].(string); greeting != "hello from server" {
|
|
|
|
|
t.Errorf("expected greeting 'hello from server', got %q", greeting)
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(3 * time.Second):
|
|
|
|
|
t.Fatal("timed out waiting for event handler to fire")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 4. Test: Concurrent Send ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestWSClient_ConcurrentSend(t *testing.T) {
|
|
|
|
|
var reqCount atomic.Int32
|
|
|
|
|
|
|
|
|
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
|
|
|
|
handleHandshake(t, conn)
|
|
|
|
|
|
|
|
|
|
// Read RPC requests and respond to each
|
|
|
|
|
for {
|
|
|
|
|
var req map[string]any
|
|
|
|
|
if err := conn.ReadJSON(&req); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
reqID, _ := req["id"].(string)
|
|
|
|
|
n := reqCount.Add(1)
|
|
|
|
|
|
|
|
|
|
res := map[string]any{
|
|
|
|
|
"type": "res",
|
|
|
|
|
"id": reqID,
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": map[string]any{"index": n, "method": req["method"]},
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(res); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
go client.Start(ctx)
|
|
|
|
|
|
|
|
|
|
// Give the client time to complete handshake
|
|
|
|
|
time.Sleep(300 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
// Fire 3 concurrent Send() calls
|
|
|
|
|
type sendResult struct {
|
|
|
|
|
method string
|
|
|
|
|
payload json.RawMessage
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
results := make(chan sendResult, 3)
|
|
|
|
|
|
|
|
|
|
methods := []string{"agents.list", "sessions.list", "agents.config"}
|
|
|
|
|
for _, method := range methods {
|
|
|
|
|
go func(m string) {
|
|
|
|
|
resp, err := client.Send(m, nil)
|
|
|
|
|
results <- sendResult{method: m, payload: resp, err: err}
|
|
|
|
|
}(method)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect all results
|
|
|
|
|
for i := 0; i < 3; i++ {
|
|
|
|
|
select {
|
|
|
|
|
case r := <-results:
|
|
|
|
|
if r.err != nil {
|
|
|
|
|
t.Errorf("Send(%q) returned error: %v", r.method, r.err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
var result map[string]any
|
|
|
|
|
if err := json.Unmarshal(r.payload, &result); err != nil {
|
|
|
|
|
t.Errorf("Send(%q) unmarshal error: %v", r.method, err)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
gotMethod, _ := result["method"].(string)
|
|
|
|
|
if gotMethod != r.method {
|
|
|
|
|
t.Errorf("Send(%q) got response for %q (mismatched)", r.method, gotMethod)
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(5 * time.Second):
|
|
|
|
|
t.Fatal("timed out waiting for concurrent Send results")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 5. Test: Clean shutdown ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestWSClient_CleanShutdown(t *testing.T) {
|
|
|
|
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
|
|
|
|
handleHandshake(t, conn)
|
|
|
|
|
keepAlive(conn)
|
|
|
|
|
})
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, nil, nil, slog.Default())
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
client.Start(ctx)
|
|
|
|
|
close(done)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Let the client connect and complete handshake
|
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
// Cancel context — should trigger clean shutdown
|
|
|
|
|
cancel()
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case <-done:
|
|
|
|
|
// Client exited cleanly — pass
|
|
|
|
|
case <-time.After(3 * time.Second):
|
|
|
|
|
t.Fatal("WSClient did not shut down cleanly within timeout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Pure utility tests (from CUB-205) ─────────────────────────────────────
|
|
|
|
|
|
2026-05-20 11:34:37 +00:00
|
|
|
func TestMapSessionStatus(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
input string
|
|
|
|
|
expected models.AgentStatus
|
|
|
|
|
}{
|
|
|
|
|
{"running", models.AgentStatusActive},
|
|
|
|
|
{"streaming", models.AgentStatusActive},
|
|
|
|
|
{"done", models.AgentStatusIdle},
|
|
|
|
|
{"error", models.AgentStatusError},
|
|
|
|
|
{"", models.AgentStatusIdle},
|
|
|
|
|
{"garbage", models.AgentStatusIdle},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
result := mapSessionStatus(tt.input)
|
|
|
|
|
if result != tt.expected {
|
|
|
|
|
t.Errorf("mapSessionStatus(%q) = %q, want %q", tt.input, result, tt.expected)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAgentItemToCard(t *testing.T) {
|
|
|
|
|
t.Run("full fields", func(t *testing.T) {
|
|
|
|
|
item := agentListItem{
|
|
|
|
|
ID: "dex",
|
|
|
|
|
Name: "Dex",
|
|
|
|
|
Role: "backend",
|
|
|
|
|
Channel: "telegram",
|
|
|
|
|
}
|
|
|
|
|
card := agentItemToCard(item)
|
|
|
|
|
if card.ID != "dex" {
|
|
|
|
|
t.Errorf("ID = %q, want %q", card.ID, "dex")
|
|
|
|
|
}
|
|
|
|
|
if card.DisplayName != "Dex" {
|
|
|
|
|
t.Errorf("DisplayName = %q, want %q", card.DisplayName, "Dex")
|
|
|
|
|
}
|
|
|
|
|
if card.Role != "backend" {
|
|
|
|
|
t.Errorf("Role = %q, want %q", card.Role, "backend")
|
|
|
|
|
}
|
|
|
|
|
if card.Channel != "telegram" {
|
|
|
|
|
t.Errorf("Channel = %q, want %q", card.Channel, "telegram")
|
|
|
|
|
}
|
|
|
|
|
if card.Status != models.AgentStatusIdle {
|
|
|
|
|
t.Errorf("Status = %q, want %q", card.Status, models.AgentStatusIdle)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("empty fields use defaults", func(t *testing.T) {
|
|
|
|
|
item := agentListItem{
|
|
|
|
|
ID: "otto",
|
|
|
|
|
}
|
|
|
|
|
card := agentItemToCard(item)
|
|
|
|
|
if card.ID != "otto" {
|
|
|
|
|
t.Errorf("ID = %q, want %q", card.ID, "otto")
|
|
|
|
|
}
|
|
|
|
|
if card.DisplayName != "otto" {
|
|
|
|
|
t.Errorf("DisplayName = %q, want %q (should fallback to ID)", card.DisplayName, "otto")
|
|
|
|
|
}
|
|
|
|
|
if card.Role != "agent" {
|
|
|
|
|
t.Errorf("Role = %q, want %q (default)", card.Role, "agent")
|
|
|
|
|
}
|
|
|
|
|
if card.Channel != "unknown" {
|
|
|
|
|
t.Errorf("Channel = %q, want %q (per Grimm requirement)", card.Channel, "unknown")
|
|
|
|
|
}
|
|
|
|
|
if card.Status != models.AgentStatusIdle {
|
|
|
|
|
t.Errorf("Status = %q, want %q", card.Status, models.AgentStatusIdle)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
t.Run("empty name falls back to ID", func(t *testing.T) {
|
|
|
|
|
item := agentListItem{
|
|
|
|
|
ID: "hex",
|
|
|
|
|
Name: "",
|
|
|
|
|
Role: "database",
|
|
|
|
|
}
|
|
|
|
|
card := agentItemToCard(item)
|
|
|
|
|
if card.DisplayName != "hex" {
|
|
|
|
|
t.Errorf("DisplayName = %q, want %q (ID fallback)", card.DisplayName, "hex")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 21:42:31 +00:00
|
|
|
// ── 6. Test: Initial sync ordering (readLoop active before Send) ──────────
|
|
|
|
|
|
|
|
|
|
// TestConnectAndRun_InitialSyncOrdering verifies that the WS client
|
|
|
|
|
// completes initial sync successfully. This test would hang/timeout if
|
|
|
|
|
// readLoop were NOT started before initialSync, because Send() relies on
|
|
|
|
|
// readLoop→routeFrame→handleResponse to deliver RPC responses.
|
|
|
|
|
func TestConnectAndRun_InitialSyncOrdering(t *testing.T) {
|
|
|
|
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
|
|
|
|
broker := handler.NewBroker()
|
|
|
|
|
capture := newBroadcastCapture(broker)
|
|
|
|
|
defer capture.close()
|
|
|
|
|
|
|
|
|
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
|
|
|
|
// Handshake
|
|
|
|
|
handleHandshake(t, conn)
|
|
|
|
|
|
|
|
|
|
// After handshake, respond to RPCs
|
|
|
|
|
for {
|
|
|
|
|
var req map[string]any
|
|
|
|
|
if err := conn.ReadJSON(&req); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
reqID, _ := req["id"].(string)
|
|
|
|
|
method, _ := req["method"].(string)
|
|
|
|
|
|
|
|
|
|
var result any
|
|
|
|
|
switch method {
|
|
|
|
|
case "agents.list":
|
|
|
|
|
result = []map[string]any{
|
|
|
|
|
{"id": "otto", "name": "Otto", "role": "Orchestrator", "channel": "discord"},
|
|
|
|
|
{"id": "dex", "name": "Dex", "role": "Backend Dev", "channel": "telegram"},
|
|
|
|
|
}
|
|
|
|
|
case "sessions.list":
|
|
|
|
|
result = []map[string]any{
|
|
|
|
|
{"sessionKey": "s1", "agentId": "otto", "status": "running", "totalTokens": 500, "lastActivityAt": "2025-05-20T12:00:00Z"},
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
result = map[string]any{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res := map[string]any{
|
|
|
|
|
"type": "res",
|
|
|
|
|
"id": reqID,
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": result,
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(res); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, repo, broker, slog.Default())
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
client.Start(ctx)
|
|
|
|
|
close(done)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Wait for initial sync to complete by checking repo state.
|
|
|
|
|
// The agents should be persisted from the RPC responses.
|
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
|
repo.mu.Lock()
|
|
|
|
|
_, ottoOK := repo.agents["otto"]
|
|
|
|
|
_, dexOK := repo.agents["dex"]
|
|
|
|
|
repo.mu.Unlock()
|
|
|
|
|
if ottoOK && dexOK {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
repo.mu.Lock()
|
|
|
|
|
_, ottoOK := repo.agents["otto"]
|
|
|
|
|
_, dexOK := repo.agents["dex"]
|
|
|
|
|
repo.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if !ottoOK {
|
|
|
|
|
t.Error("otto not found in repo after initial sync — readLoop may not have been active before Send()")
|
|
|
|
|
}
|
|
|
|
|
if !dexOK {
|
|
|
|
|
t.Error("dex not found in repo after initial sync — readLoop may not have been active before Send()")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
|
select {
|
|
|
|
|
case <-done:
|
|
|
|
|
case <-time.After(3 * time.Second):
|
|
|
|
|
t.Fatal("WSClient did not shut down cleanly")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 21:52:39 +00:00
|
|
|
// ── 7. Test: Event not lost during initial sync (regression) ───────────────
|
|
|
|
|
|
|
|
|
|
// TestConnectAndRun_EventNotLostDuringSync verifies that live gateway events
|
|
|
|
|
// arriving during initial sync are NOT dropped. This is a regression test
|
|
|
|
|
// for the race where readLoop started before registerEventHandlers(),
|
|
|
|
|
// causing events read during that window to be logged as "unhandled" and lost.
|
|
|
|
|
//
|
|
|
|
|
// The mock server sends a live event (sessions.changed) right after the
|
|
|
|
|
// handshake, interleaved with the RPC responses for agents.list and
|
|
|
|
|
// sessions.list. The test asserts the event is received by the handler.
|
|
|
|
|
func TestConnectAndRun_EventNotLostDuringSync(t *testing.T) {
|
|
|
|
|
repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)}
|
|
|
|
|
broker := handler.NewBroker()
|
|
|
|
|
capture := newBroadcastCapture(broker)
|
|
|
|
|
defer capture.close()
|
|
|
|
|
|
|
|
|
|
// Pre-seed an agent so the event handler can update it.
|
|
|
|
|
repo.agents["otto"] = models.AgentCardData{
|
|
|
|
|
ID: "otto",
|
|
|
|
|
DisplayName: "Otto",
|
|
|
|
|
Status: models.AgentStatusIdle,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
srv := newTestWSServer(t, func(conn *websocket.Conn) {
|
|
|
|
|
// Handshake
|
|
|
|
|
handleHandshake(t, conn)
|
|
|
|
|
|
|
|
|
|
// After handshake, process RPCs and inject a live event.
|
|
|
|
|
for {
|
|
|
|
|
var req map[string]any
|
|
|
|
|
if err := conn.ReadJSON(&req); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
reqID, _ := req["id"].(string)
|
|
|
|
|
method, _ := req["method"].(string)
|
|
|
|
|
|
|
|
|
|
// Respond to agents.list RPC
|
|
|
|
|
if method == "agents.list" {
|
|
|
|
|
// Before responding, inject a live event — simulates
|
|
|
|
|
// a gateway pushing a presence update during sync.
|
|
|
|
|
evt := map[string]any{
|
|
|
|
|
"type": "event",
|
|
|
|
|
"event": "presence",
|
|
|
|
|
"params": map[string]any{"agentId": "otto", "connected": true, "lastActivityAt": "2025-05-20T12:30:00Z"},
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(evt); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Now send the RPC response
|
|
|
|
|
res := map[string]any{
|
|
|
|
|
"type": "res",
|
|
|
|
|
"id": reqID,
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": []map[string]any{
|
|
|
|
|
{"id": "otto", "name": "Otto", "role": "Orchestrator", "channel": "discord"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(res); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Respond to sessions.list RPC
|
|
|
|
|
if method == "sessions.list" {
|
|
|
|
|
res := map[string]any{
|
|
|
|
|
"type": "res",
|
|
|
|
|
"id": reqID,
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": []map[string]any{},
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(res); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Default response for other methods
|
|
|
|
|
res := map[string]any{
|
|
|
|
|
"type": "res",
|
|
|
|
|
"id": reqID,
|
|
|
|
|
"ok": true,
|
|
|
|
|
"result": map[string]any{},
|
|
|
|
|
}
|
|
|
|
|
if err := conn.WriteJSON(res); err != nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
client := NewWSClient(WSConfig{URL: wsURL(srv), AuthToken: "test-token"}, repo, broker, slog.Default())
|
|
|
|
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
done := make(chan struct{})
|
|
|
|
|
go func() {
|
|
|
|
|
client.Start(ctx)
|
|
|
|
|
close(done)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
// Wait for the presence event to be processed by checking the repo.
|
|
|
|
|
// The presence handler updates the agent, so we check for the
|
|
|
|
|
// lastActivityAt change.
|
|
|
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
|
|
|
var lastActivity string
|
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
|
repo.mu.Lock()
|
|
|
|
|
if a, ok := repo.agents["otto"]; ok {
|
|
|
|
|
lastActivity = a.LastActivity
|
|
|
|
|
}
|
|
|
|
|
repo.mu.Unlock()
|
|
|
|
|
if lastActivity == "2025-05-20T12:30:00Z" {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if lastActivity != "2025-05-20T12:30:00Z" {
|
|
|
|
|
t.Errorf("presence event during sync was lost: lastActivity = %q, want %q", lastActivity, "2025-05-20T12:30:00Z")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancel()
|
|
|
|
|
select {
|
|
|
|
|
case <-done:
|
|
|
|
|
case <-time.After(3 * time.Second):
|
|
|
|
|
t.Fatal("WSClient did not shut down cleanly")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 11:34:37 +00:00
|
|
|
func TestStrPtr(t *testing.T) {
|
|
|
|
|
s := "hello"
|
|
|
|
|
p := strPtr(s)
|
|
|
|
|
if p == nil {
|
|
|
|
|
t.Fatal("strPtr returned nil")
|
|
|
|
|
}
|
|
|
|
|
if *p != s {
|
|
|
|
|
t.Errorf("strPtr(%q) = %q, want %q", s, *p, s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
empty := ""
|
|
|
|
|
ep := strPtr(empty)
|
|
|
|
|
if *ep != empty {
|
|
|
|
|
t.Errorf("strPtr(empty) = %q, want %q", *ep, empty)
|
|
|
|
|
}
|
|
|
|
|
}
|