370 lines
12 KiB
Go
370 lines
12 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"
|
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// testHandler creates a Handler wired to fresh in-memory stores for testing.
|
|
func testHandler(t *testing.T) *Handler {
|
|
t.Helper()
|
|
return NewHandler(
|
|
store.NewAgentStore(),
|
|
store.NewSessionStore(),
|
|
store.NewTaskStore(),
|
|
store.NewProjectStore(),
|
|
)
|
|
}
|
|
|
|
// 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=dax, 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"}`)
|
|
serveChi(h, "PUT", "/api/agents/nano", `{"status":"thinking","currentTask":"mqtt payload"}`)
|
|
|
|
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)
|
|
if len(entries) < 2 {
|
|
t.Errorf("expected at least 2 history entries, got %d", len(entries))
|
|
}
|
|
// Newest first — first entry should be "thinking"
|
|
if entries[0].Status != models.AgentStatusThinking {
|
|
t.Errorf("expected newest entry status=thinking, got %s", entries[0].Status)
|
|
}
|
|
}
|
|
|
|
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.SessionStore.Create(models.Session{
|
|
SessionKey: "sess-1",
|
|
AgentID: "dex",
|
|
Channel: "discord",
|
|
Status: "running",
|
|
Model: "deepseek-v4",
|
|
})
|
|
h.SessionStore.Create(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.TaskStore.Create(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.ProjectStore.Create(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)))
|
|
}
|
|
// HasMore=false because we're past all data — nothing more to fetch.
|
|
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)
|
|
}
|
|
}
|