package gateway import ( "context" "encoding/json" "fmt" "log/slog" "sync" "testing" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler" "code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models" ) // ── Mock AgentRepo ──────────────────────────────────────────────────────── type mockAgentRepo struct { mu sync.Mutex agents map[string]models.AgentCardData updateCalls []updateCall } type updateCall struct { id string req models.UpdateAgentRequest } func (m *mockAgentRepo) Get(_ context.Context, id string) (models.AgentCardData, error) { m.mu.Lock() defer m.mu.Unlock() a, ok := m.agents[id] if !ok { return models.AgentCardData{}, errNotFound } return a, nil } func (m *mockAgentRepo) Update(_ context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) { m.mu.Lock() defer m.mu.Unlock() a, ok := m.agents[id] if !ok { return models.AgentCardData{}, errNotFound } if req.Status != nil { a.Status = *req.Status } if req.DisplayName != nil { a.DisplayName = *req.DisplayName } if req.Role != nil { a.Role = *req.Role } if req.Channel != nil { a.Channel = *req.Channel } if req.CurrentTask != nil { a.CurrentTask = req.CurrentTask } if req.TaskProgress != nil { a.TaskProgress = req.TaskProgress } if req.TaskElapsed != nil { a.TaskElapsed = req.TaskElapsed } if req.ErrorMessage != nil { a.ErrorMessage = req.ErrorMessage } if req.LastActivityAt != nil { a.LastActivity = *req.LastActivityAt } m.agents[id] = a m.updateCalls = append(m.updateCalls, updateCall{id, req}) return a, nil } func (m *mockAgentRepo) Create(_ context.Context, a models.AgentCardData) error { m.mu.Lock() defer m.mu.Unlock() m.agents[a.ID] = a return nil } func (m *mockAgentRepo) List(_ context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) { m.mu.Lock() defer m.mu.Unlock() var result []models.AgentCardData for _, a := range m.agents { if statusFilter == "" || a.Status == statusFilter { result = append(result, a) } } return result, nil } func (m *mockAgentRepo) Delete(_ context.Context, id string) error { m.mu.Lock() defer m.mu.Unlock() delete(m.agents, id) return nil } func (m *mockAgentRepo) Count(_ context.Context) (int, error) { m.mu.Lock() defer m.mu.Unlock() return len(m.agents), nil } // errNotFound is returned by the mock repo when an agent is not found. var errNotFound = fmt.Errorf("not found") // ── Broadcast capture helper ─────────────────────────────────────────────── // broadcastCapture wraps a real Broker and captures all broadcasts // via a subscribed channel. Use captured() to retrieve events that have // been received so far. Call close() to unsubscribe when done. type broadcastCapture struct { broker *handler.Broker ch chan handler.SSEEvent } func newBroadcastCapture(broker *handler.Broker) *broadcastCapture { return &broadcastCapture{ broker: broker, ch: broker.Subscribe(), } } // captured drains all pending events from the subscription channel // and returns them. This is synchronous — it only returns events that // have already been sent to the channel. func (bc *broadcastCapture) captured() []handler.SSEEvent { var events []handler.SSEEvent for { select { case evt := <-bc.ch: events = append(events, evt) default: return events } } } func (bc *broadcastCapture) close() { bc.broker.Unsubscribe(bc.ch) } // ── Test helpers ────────────────────────────────────────────────────────── // newTestWSClient creates a WSClient wired to a mock repo and a real broker. // Returns the client, the mock repo, and a broadcast capture. func newTestWSClient() (*WSClient, *mockAgentRepo, *handler.Broker, *broadcastCapture) { repo := &mockAgentRepo{agents: make(map[string]models.AgentCardData)} broker := handler.NewBroker() capture := newBroadcastCapture(broker) client := NewWSClient(WSConfig{}, repo, broker, slog.Default()) return client, repo, broker, capture } // ── Tests ───────────────────────────────────────────────────────────────── func TestHandleSessionsChanged_Active(t *testing.T) { client, repo, broker, capture := newTestWSClient() defer capture.close() repo.agents["otto"] = models.AgentCardData{ ID: "otto", DisplayName: "Otto", Status: models.AgentStatusIdle, } payload := json.RawMessage(`{ "sessionKey": "s1", "agentId": "otto", "status": "running", "totalTokens": 500, "currentTask": "Orchestrating tasks" }`) client.handleSessionsChanged(payload) // Verify: agent status updated to active repo.mu.Lock() agent := repo.agents["otto"] calls := make([]updateCall, len(repo.updateCalls)) copy(calls, repo.updateCalls) repo.mu.Unlock() if agent.Status != models.AgentStatusActive { t.Errorf("agent status = %q, want %q", agent.Status, models.AgentStatusActive) } // Verify: update was called if len(calls) == 0 { t.Fatal("expected at least one update call") } if calls[0].id != "otto" { t.Errorf("update call agentId = %q, want %q", calls[0].id, "otto") } // Verify: broker broadcast "agent.status" events := capture.captured() found := false for _, evt := range events { if evt.EventType == "agent.status" { found = true break } } if !found { t.Error("expected broker broadcast with event type 'agent.status'") } } func TestHandleSessionsChanged_Idle(t *testing.T) { client, repo, _, capture := newTestWSClient() defer capture.close() repo.agents["dex"] = models.AgentCardData{ ID: "dex", DisplayName: "Dex", Status: models.AgentStatusActive, CurrentTask: strPtr("Writing API"), } payload := json.RawMessage(`{ "sessionKey": "s2", "agentId": "dex", "status": "done", "totalTokens": 1000 }`) client.handleSessionsChanged(payload) repo.mu.Lock() agent := repo.agents["dex"] repo.mu.Unlock() // Verify: agent goes idle if agent.Status != models.AgentStatusIdle { t.Errorf("agent status = %q, want %q", agent.Status, models.AgentStatusIdle) } // Verify: current task cleared (set to empty string) if agent.CurrentTask != nil && *agent.CurrentTask != "" { t.Errorf("current task = %q, want empty (cleared on idle)", *agent.CurrentTask) } // Verify: broker fires "agent.status" events := capture.captured() found := false for _, evt := range events { if evt.EventType == "agent.status" { found = true break } } if !found { t.Error("expected broker broadcast with event type 'agent.status'") } } func TestHandleSessionsChanged_ArrayPayload(t *testing.T) { client, repo, _, capture := newTestWSClient() defer capture.close() repo.agents["otto"] = models.AgentCardData{ID: "otto", DisplayName: "Otto", Status: models.AgentStatusIdle} repo.agents["dex"] = models.AgentCardData{ID: "dex", DisplayName: "Dex", Status: models.AgentStatusIdle} payload := json.RawMessage(`[ {"sessionKey":"s1","agentId":"otto","status":"running","totalTokens":100}, {"sessionKey":"s2","agentId":"dex","status":"streaming","totalTokens":200} ]`) client.handleSessionsChanged(payload) repo.mu.Lock() otto := repo.agents["otto"] dex := repo.agents["dex"] repo.mu.Unlock() if otto.Status != models.AgentStatusActive { t.Errorf("otto status = %q, want active", otto.Status) } if dex.Status != models.AgentStatusActive { t.Errorf("dex status = %q, want active", dex.Status) } // Both should produce broadcasts events := capture.captured() statusCount := 0 for _, evt := range events { if evt.EventType == "agent.status" { statusCount++ } } if statusCount < 2 { t.Errorf("expected at least 2 agent.status broadcasts, got %d", statusCount) } } func TestHandleSessionsChanged_SkipsEmptyAgentID(t *testing.T) { client, _, _, capture := newTestWSClient() defer capture.close() payload := json.RawMessage(`{"sessionKey":"s1","agentId":"","status":"running"}`) client.handleSessionsChanged(payload) events := capture.captured() if len(events) > 0 { t.Errorf("expected no broadcasts for empty agentId, got %d", len(events)) } } func TestHandleSessionsChanged_UnparseablePayload(t *testing.T) { client, _, _, capture := newTestWSClient() defer capture.close() payload := json.RawMessage(`not json at all`) client.handleSessionsChanged(payload) events := capture.captured() if len(events) > 0 { t.Errorf("expected no broadcasts for unparseable payload, got %d", len(events)) } } func TestHandlePresence(t *testing.T) { client, repo, _, capture := newTestWSClient() defer capture.close() repo.agents["pip"] = models.AgentCardData{ ID: "pip", DisplayName: "Pip", Status: models.AgentStatusActive, } payload := json.RawMessage(`{ "agentId": "pip", "connected": true, "lastActivityAt": "2025-01-01T00:00:00Z" }`) client.handlePresence(payload) repo.mu.Lock() agent := repo.agents["pip"] calls := make([]updateCall, len(repo.updateCalls)) copy(calls, repo.updateCalls) repo.mu.Unlock() // Agent should still be active (connected=true doesn't change status) if agent.Status != models.AgentStatusActive { t.Errorf("agent status = %q, want active", agent.Status) } // Update should have been called (for lastActivityAt) if len(calls) == 0 { t.Fatal("expected at least one update call") } // Verify broadcast events := capture.captured() found := false for _, evt := range events { if evt.EventType == "agent.status" { found = true break } } if !found { t.Error("expected broker broadcast with event type 'agent.status'") } } func TestHandlePresence_Disconnect(t *testing.T) { client, repo, _, capture := newTestWSClient() defer capture.close() repo.agents["pip"] = models.AgentCardData{ ID: "pip", DisplayName: "Pip", Status: models.AgentStatusActive, } payload := json.RawMessage(`{ "agentId": "pip", "connected": false }`) client.handlePresence(payload) repo.mu.Lock() agent := repo.agents["pip"] repo.mu.Unlock() // Agent should go idle on disconnect if agent.Status != models.AgentStatusIdle { t.Errorf("agent status = %q, want idle after disconnect", agent.Status) } events := capture.captured() found := false for _, evt := range events { if evt.EventType == "agent.status" { found = true break } } if !found { t.Error("expected broker broadcast with event type 'agent.status' on disconnect") } } func TestHandlePresence_EmptyAgentID(t *testing.T) { client, _, _, capture := newTestWSClient() defer capture.close() payload := json.RawMessage(`{"agentId":"","connected":true}`) client.handlePresence(payload) events := capture.captured() if len(events) > 0 { t.Errorf("expected no broadcasts for empty agentId, got %d", len(events)) } } func TestHandleAgentConfig(t *testing.T) { client, repo, _, capture := newTestWSClient() defer capture.close() repo.agents["rex"] = models.AgentCardData{ ID: "rex", DisplayName: "Rex", Role: "Frontend Dev", Status: models.AgentStatusIdle, Channel: "discord", } payload := json.RawMessage(`{ "id": "rex", "name": "Rex the Dev", "role": "Senior Frontend", "channel": "telegram" }`) client.handleAgentConfig(payload) repo.mu.Lock() agent := repo.agents["rex"] calls := make([]updateCall, len(repo.updateCalls)) copy(calls, repo.updateCalls) repo.mu.Unlock() // Verify DisplayName and Role updated if agent.DisplayName != "Rex the Dev" { t.Errorf("displayName = %q, want %q", agent.DisplayName, "Rex the Dev") } if agent.Role != "Senior Frontend" { t.Errorf("role = %q, want %q", agent.Role, "Senior Frontend") } if agent.Channel != "telegram" { t.Errorf("channel = %q, want %q", agent.Channel, "telegram") } // Verify update was called if len(calls) == 0 { t.Fatal("expected at least one update call") } // Verify broker fires "fleet.update" events := capture.captured() found := false for _, evt := range events { if evt.EventType == "fleet.update" { found = true break } } if !found { t.Error("expected broker broadcast with event type 'fleet.update'") } } func TestHandleAgentConfig_EmptyID(t *testing.T) { client, _, _, capture := newTestWSClient() defer capture.close() payload := json.RawMessage(`{"id":"","name":"Ghost"}`) client.handleAgentConfig(payload) events := capture.captured() if len(events) > 0 { t.Errorf("expected no broadcasts for empty id, got %d", len(events)) } } func TestHandleAgentConfig_NotFound(t *testing.T) { client, _, _, capture := newTestWSClient() defer capture.close() payload := json.RawMessage(`{"id":"unknown","name":"Ghost","role":"Phantom"}`) client.handleAgentConfig(payload) // Agent doesn't exist in repo, so Update will fail → handler logs warning, returns early events := capture.captured() for _, evt := range events { if evt.EventType == "fleet.update" { t.Error("fleet.update should not be broadcast when agent update fails") } } }