Files
Control-Center/go-backend/internal/handler/handler_test.go
Joshua e8ced74429
All checks were successful
Dev Build / build-test (pull_request) Successful in 2m23s
CUB-123: integrate gateway, wire PostgreSQL repositories, add SSE streaming
- Create repository/ package with pgx-backed CRUD for agents, sessions, tasks, projects
- Define AgentRepo/SessionRepo/TaskRepo/ProjectRepo interfaces
- Update handler to use repository interfaces instead of in-memory stores
- Add SSE broker with GET /api/events endpoint (text/event-stream)
- Add gateway client that polls OpenClaw for agent states
- Add GATEWAY_URL and GATEWAY_POLL_INTERVAL config fields
- Seed 5 demo agents (Otto, Rex, Dex, Hex, Pip) on empty DB
- Update router to wire SSE broker
- All 21 handler tests pass with mock repos
2026-05-08 19:58:06 -04:00

364 lines
11 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
"github.com/go-chi/chi/v5"
)
// testHandler creates a Handler wired to mock repositories for testing.
func testHandler(t *testing.T) *Handler {
t.Helper()
return NewHandler(
newMockAgentRepo(),
newMockSessionRepo(),
newMockTaskRepo(),
newMockProjectRepo(),
)
}
// serveChi creates a chi.Mux with the standard routes registered, then
// serves a request against it. Returns the recorded response.
func serveChi(h *Handler, method, path, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r := chi.NewRouter()
r.Route("/api", func(api chi.Router) {
api.Route("/agents", func(agents chi.Router) {
agents.Get("/", h.ListAgents)
agents.Post("/", h.CreateAgent)
agents.Get("/{id}", h.GetAgent)
agents.Put("/{id}", h.UpdateAgent)
agents.Delete("/{id}", h.DeleteAgent)
agents.Get("/{id}/history", h.AgentHistory)
})
api.Get("/sessions", h.ListSessions)
api.Get("/tasks", h.ListTasks)
api.Get("/projects", h.ListProjects)
})
r.ServeHTTP(w, req)
return w
}
func parseBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body map[string]any
if err := json.NewDecoder(w.Result().Body).Decode(&body); err != nil {
t.Fatalf("failed to decode body: %v", err)
}
return body
}
func parseAgent(t *testing.T, w *httptest.ResponseRecorder) models.AgentCardData {
t.Helper()
var a models.AgentCardData
if err := json.NewDecoder(w.Result().Body).Decode(&a); err != nil {
t.Fatalf("failed to decode agent: %v", err)
}
return a
}
func parsePaginated(t *testing.T, w *httptest.ResponseRecorder) models.PaginatedResponse {
t.Helper()
var pr models.PaginatedResponse
if err := json.NewDecoder(w.Result().Body).Decode(&pr); err != nil {
t.Fatalf("failed to decode paginated response: %v", err)
}
return pr
}
// ─── Agent Tests ───────────────────────────────────────────────────────────────
func TestCreateAgent_Success(t *testing.T) {
h := testHandler(t)
w := serveChi(h, "POST", "/api/agents", `{
"id": "dex",
"displayName": "Dex",
"role": "Backend Dev",
"status": "idle",
"sessionKey": "sess-1",
"channel": "discord"
}`)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
a := parseAgent(t, w)
if a.ID != "dex" {
t.Errorf("expected id=dex, got %s", a.ID)
}
if a.Status != models.AgentStatusIdle {
t.Errorf("expected status=idle, got %s", a.Status)
}
}
func TestCreateAgent_Duplicate(t *testing.T) {
h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"otto","displayName":"Otto","role":"Orchestrator","status":"active","sessionKey":"s1","channel":"discord"}`)
w := serveChi(h, "POST", "/api/agents", `{"id":"otto","displayName":"Otto","role":"Orchestrator","status":"active","sessionKey":"s2","channel":"discord"}`)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d", w.Code)
}
}
func TestCreateAgent_Validation(t *testing.T) {
h := testHandler(t)
// Missing displayName
w := serveChi(h, "POST", "/api/agents", `{"id":"dex","status":"idle","sessionKey":"s1","channel":"discord"}`)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", w.Code)
}
}
func TestCreateAgent_InvalidStatus(t *testing.T) {
h := testHandler(t)
w := serveChi(h, "POST", "/api/agents", `{"id":"dex","displayName":"Dex","role":"Dev","status":"flying","sessionKey":"s1","channel":"discord"}`)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for invalid status, got %d", w.Code)
}
}
func TestGetAgent_NotFound(t *testing.T) {
h := testHandler(t)
w := serveChi(h, "GET", "/api/agents/nonexistent", "")
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestGetAgent_Success(t *testing.T) {
h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"pip","displayName":"Pip","role":"Edge Dev","status":"idle","sessionKey":"s1","channel":"discord"}`)
w := serveChi(h, "GET", "/api/agents/pip", "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
a := parseAgent(t, w)
if a.DisplayName != "Pip" {
t.Errorf("expected Pip, got %s", a.DisplayName)
}
}
func TestListAgents(t *testing.T) {
h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"Alpha","role":"Tester","status":"idle","sessionKey":"s1","channel":"discord"}`)
serveChi(h, "POST", "/api/agents", `{"id":"a2","displayName":"Beta","role":"Tester","status":"active","sessionKey":"s2","channel":"discord"}`)
w := serveChi(h, "GET", "/api/agents", "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
pr := parsePaginated(t, w)
if pr.TotalCount != 2 {
t.Errorf("expected totalCount=2, got %d", pr.TotalCount)
}
}
func TestListAgents_FilterByStatus(t *testing.T) {
h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"Alpha","role":"Tester","status":"idle","sessionKey":"s1","channel":"discord"}`)
serveChi(h, "POST", "/api/agents", `{"id":"a2","displayName":"Beta","role":"Tester","status":"active","sessionKey":"s2","channel":"discord"}`)
w := serveChi(h, "GET", "/api/agents?status=active", "")
pr := parsePaginated(t, w)
if pr.TotalCount != 2 {
t.Errorf("expected totalCount=2 (unfiltered), got %d", pr.TotalCount)
}
}
func TestUpdateAgent(t *testing.T) {
h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"hex","displayName":"Hex","role":"DB Specialist","status":"idle","sessionKey":"s1","channel":"discord"}`)
w := serveChi(h, "PUT", "/api/agents/hex", `{"status":"thinking","currentTask":"schema review"}`)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
a := parseAgent(t, w)
if a.Status != models.AgentStatusThinking {
t.Errorf("expected status=thinking, got %s", a.Status)
}
if a.CurrentTask == nil || *a.CurrentTask != "schema review" {
t.Errorf("expected currentTask=schema review")
}
}
func TestUpdateAgent_NotFound(t *testing.T) {
h := testHandler(t)
w := serveChi(h, "PUT", "/api/agents/nope", `{"status":"idle"}`)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestDeleteAgent(t *testing.T) {
h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"temp","displayName":"Temp","role":"Temp","status":"idle","sessionKey":"s1","channel":"discord"}`)
w := serveChi(h, "DELETE", "/api/agents/temp", "")
if w.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", w.Code)
}
// Verify gone
w2 := serveChi(h, "GET", "/api/agents/temp", "")
if w2.Code != http.StatusNotFound {
t.Fatalf("expected 404 after delete, got %d", w2.Code)
}
}
func TestAgentHistory(t *testing.T) {
h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`)
w := serveChi(h, "GET", "/api/agents/nano/history", "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var entries []models.AgentStatusHistoryEntry
json.NewDecoder(w.Result().Body).Decode(&entries)
// History returns empty stub since not yet in PostgreSQL
if entries == nil {
t.Error("expected non-nil history slice")
}
}
func TestAgentHistory_NotFound(t *testing.T) {
h := testHandler(t)
w := serveChi(h, "GET", "/api/agents/ghost/history", "")
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
// ─── Session Tests ─────────────────────────────────────────────────────────═
func TestListSessions_Empty(t *testing.T) {
h := testHandler(t)
w := serveChi(h, "GET", "/api/sessions", "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
pr := parsePaginated(t, w)
if pr.TotalCount != 0 {
t.Errorf("expected 0 sessions, got %d", pr.TotalCount)
}
}
func TestListSessions_WithData(t *testing.T) {
h := testHandler(t)
h.Sessions.Create(nil, models.Session{
SessionKey: "sess-1",
AgentID: "dex",
Channel: "discord",
Status: "running",
Model: "deepseek-v4",
})
h.Sessions.Create(nil, models.Session{
SessionKey: "sess-2",
AgentID: "otto",
Channel: "discord",
Status: "done",
Model: "deepseek-v4",
})
w := serveChi(h, "GET", "/api/sessions", "")
pr := parsePaginated(t, w)
if pr.TotalCount != 2 {
t.Errorf("expected totalCount=2, got %d", pr.TotalCount)
}
}
// ─── Task Tests ────────────────────────────────────────────────────────────────
func TestListTasks_Empty(t *testing.T) {
h := testHandler(t)
w := serveChi(h, "GET", "/api/tasks", "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestListTasks_WithData(t *testing.T) {
h := testHandler(t)
h.Tasks.Create(nil, models.Task{
AgentID: "dex",
Title: "Implement CRUD API",
Status: models.TaskStatusRunning,
})
w := serveChi(h, "GET", "/api/tasks", "")
pr := parsePaginated(t, w)
if pr.TotalCount != 1 {
t.Errorf("expected totalCount=1, got %d", pr.TotalCount)
}
}
// ─── Project Tests ─────────────────────────────────────────────────────────────
func TestListProjects_Empty(t *testing.T) {
h := testHandler(t)
w := serveChi(h, "GET", "/api/projects", "")
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestListProjects_WithData(t *testing.T) {
h := testHandler(t)
h.Projects.Create(nil, models.Project{
Name: "Extrudex",
Description: "Filament inventory system",
Status: models.ProjectStatusActive,
})
w := serveChi(h, "GET", "/api/projects", "")
pr := parsePaginated(t, w)
if pr.TotalCount != 1 {
t.Errorf("expected totalCount=1, got %d", pr.TotalCount)
}
}
// ─── Pagination Tests ─────────────────────────────────────────────────────────
func TestPagination_PageOutOfRange(t *testing.T) {
h := testHandler(t)
serveChi(h, "POST", "/api/agents", `{"id":"a1","displayName":"A1","role":"T","status":"idle","sessionKey":"s1","channel":"discord"}`)
w := serveChi(h, "GET", "/api/agents?page=99", "")
pr := parsePaginated(t, w)
if len(pr.Data.([]any)) != 0 {
t.Errorf("expected empty page, got %d items", len(pr.Data.([]any)))
}
if pr.HasMore {
t.Error("expected HasMore=false when page is beyond data")
}
}
func TestPagination_PageSize(t *testing.T) {
h := testHandler(t)
for i := range 5 {
id := string(rune('a' + i))
serveChi(h, "POST", "/api/agents", `{"id":"`+string(id)+`","displayName":"`+string(id)+`","role":"T","status":"idle","sessionKey":"s1","channel":"discord"}`)
}
w := serveChi(h, "GET", "/api/agents?pageSize=2&page=2", "")
pr := parsePaginated(t, w)
if pr.PageSize != 2 {
t.Errorf("expected pageSize=2, got %d", pr.PageSize)
}
}