Move registerEventHandlers() call before the readLoop goroutine starts in connectAndRun(). This eliminates the startup window where live gateway events were actively read and dropped as 'unhandled' because handler registration happened only after initialSync completed. The handlers only depend on c.agents and c.broker, which are wired in the constructor — they do not require initialSync to have completed. Also adds TestConnectAndRun_EventNotLostDuringSync regression test that sends a live presence event during initial sync and asserts it is not lost. All gateway tests pass with -race.
715 lines
19 KiB
Go
715 lines
19 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// ── 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) ─────────────────────────────────────
|
|
|
|
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")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── 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")
|
|
}
|
|
}
|
|
|
|
// ── 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")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
} |