diff --git a/canvas/src/store/__tests__/canvas.test.ts b/canvas/src/store/__tests__/canvas.test.ts index e3410b147..1667c9ede 100644 --- a/canvas/src/store/__tests__/canvas.test.ts +++ b/canvas/src/store/__tests__/canvas.test.ts @@ -1224,3 +1224,117 @@ describe("moveNode", () => { }); }); }); + +describe("arrangeChildren", () => { + it("is a no-op when the parent has no children", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "parent", name: "Parent", x: 100, y: 200 }), + ]); + expect(() => useCanvasStore.getState().arrangeChildren("parent")).not.toThrow(); + // No fetch calls should be made + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("sorts children by name and assigns default slot positions", () => { + // Children are: Bob, Alice — after localeSort: Alice(0), Bob(1) + // defaultChildSlot(0) = {x: 16, y: 130} (PARENT_SIDE_PADDING, PARENT_HEADER_PADDING) + // defaultChildSlot(1) = {x: 270, y: 130} (16 + 240 + 14, 130) + useCanvasStore.getState().hydrate([ + makeWS({ id: "parent", name: "Parent", x: 100, y: 200 }), + makeWS({ id: "ws-bob", name: "Bob", x: 0, y: 0, parent_id: "parent" }), + makeWS({ id: "ws-alice", name: "Alice", x: 0, y: 0, parent_id: "parent" }), + ]); + + useCanvasStore.getState().arrangeChildren("parent"); + + const nodes = useCanvasStore.getState().nodes; + const alice = nodes.find((n) => n.id === "ws-alice")!; + const bob = nodes.find((n) => n.id === "ws-bob")!; + + // Alice is first alphabetically → index 0 → {x: 16, y: 130} + expect(alice.position).toEqual({ x: 16, y: 130 }); + // Bob is second alphabetically → index 1 → {x: 270, y: 130} + expect(bob.position).toEqual({ x: 270, y: 130 }); + }); + + it("PATCHes each child with absolute canvas coordinates (parent position + slot)", async () => { + // Parent at (100, 200). Alice slot = {x: 16, y: 130}. + // absX = 16 + 100 = 116, absY = 130 + 200 = 330. + const mock = global.fetch as ReturnType; + useCanvasStore.getState().hydrate([ + makeWS({ id: "parent", name: "Parent", x: 100, y: 200 }), + makeWS({ id: "ws-alice", name: "Alice", x: 0, y: 0, parent_id: "parent" }), + ]); + + useCanvasStore.getState().arrangeChildren("parent"); + + await vi.waitFor(() => { + expect(mock).toHaveBeenCalledWith( + expect.stringContaining("/workspaces/ws-alice"), + expect.objectContaining({ + method: "PATCH", + body: JSON.stringify({ x: 116, y: 330 }), + }), + ); + }); + }); +}); + +describe("setCollapsed", () => { + it("collapsing a parent hides its direct child", () => { + // parentMinSizeFromChildren([{width:240, height:130}]) = {width: 560, height: 302} + useCanvasStore.getState().hydrate([ + makeWS({ id: "parent", name: "Parent" }), + makeWS({ id: "child", name: "Child", parent_id: "parent" }), + ]); + // Manually set parent size so it has an expanded size + useCanvasStore.setState({ + nodes: useCanvasStore.getState().nodes.map((n) => + n.id === "parent" ? { ...n, width: 560, height: 302 } : n, + ), + }); + + useCanvasStore.getState().setCollapsed("parent", true); + + const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!; + const child = useCanvasStore.getState().nodes.find((n) => n.id === "child")!; + + expect(parent.data.collapsed).toBe(true); + expect(parent.width).toBe(240); // CHILD_DEFAULT_WIDTH + expect(parent.height).toBe(130); // CHILD_DEFAULT_HEIGHT + expect(child.hidden).toBe(true); // child is hidden because parent is collapsed + }); + + it("expanding a parent reveals its direct child", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "parent", name: "Parent" }), + makeWS({ id: "child", name: "Child", parent_id: "parent" }), + ]); + useCanvasStore.setState({ + nodes: useCanvasStore.getState().nodes.map((n) => + n.id === "parent" + ? { ...n, width: 240, height: 130, data: { ...n.data, collapsed: true } } + : n, + ), + }); + + useCanvasStore.getState().setCollapsed("parent", false); + + const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!; + const child = useCanvasStore.getState().nodes.find((n) => n.id === "child")!; + + expect(parent.data.collapsed).toBe(false); + expect(child.hidden).toBe(false); // child is visible when parent is expanded + }); + + it("is a no-op for a non-existent parentId", () => { + useCanvasStore.getState().hydrate([ + makeWS({ id: "parent", name: "Parent" }), + makeWS({ id: "child", name: "Child", parent_id: "parent" }), + ]); + // Should not throw even when parentId doesn't exist + expect(() => useCanvasStore.getState().setCollapsed("nonexistent", true)).not.toThrow(); + // Nodes should be unchanged + expect(useCanvasStore.getState().nodes).toHaveLength(2); + }); +}); diff --git a/workspace-server/internal/handlers/a2a_queue_status_test.go b/workspace-server/internal/handlers/a2a_queue_status_test.go index 1bae7fbdc..cb4044468 100644 --- a/workspace-server/internal/handlers/a2a_queue_status_test.go +++ b/workspace-server/internal/handlers/a2a_queue_status_test.go @@ -1,7 +1,16 @@ package handlers import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" ) // TestExtractExpiresInSeconds covers the JSON parser used at enqueue time @@ -58,3 +67,361 @@ func TestExtractExpiresInSeconds(t *testing.T) { }) } } + +// ─── QueueDepth ───────────────────────────────────────────────────────────── + +// TestQueueDepth_Success verifies QueueDepth returns the COUNT of queued items +// for a workspace. +func TestQueueDepth_Success(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue WHERE workspace_id = \$1 AND status = 'queued'`). + WithArgs("ws-queue-depth-1"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(7)) + + got := QueueDepth(context.Background(), "ws-queue-depth-1") + if got != 7 { + t.Errorf("QueueDepth() = %d; want 7", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// TestQueueDepth_EmptyQueue returns 0 when no queued items exist. +func TestQueueDepth_EmptyQueue(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue WHERE workspace_id = \$1 AND status = 'queued'`). + WithArgs("ws-empty"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + got := QueueDepth(context.Background(), "ws-empty") + if got != 0 { + t.Errorf("QueueDepth() = %d; want 0", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// TestQueueDepth_QueryError returns 0 on DB error (non-fatal; caller only uses +// the count for display purposes). +func TestQueueDepth_QueryError_ReturnsZero(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT COUNT\(\*\) FROM a2a_queue WHERE workspace_id = \$1 AND status = 'queued'`). + WithArgs("ws-err"). + WillReturnError(errors.New("connection refused")) + + // QueueDepth swallows the error and returns 0. + got := QueueDepth(context.Background(), "ws-err") + if got != 0 { + t.Errorf("QueueDepth() on error = %d; want 0", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// ─── QueueStatusByID ──────────────────────────────────────────────────────── + +// TestQueueStatusByID_Success verifies QueueStatusByID returns a fully-populated +// QueueStatus from the LEFT JOIN of a2a_queue and activity_logs. +func TestQueueStatusByID_Success(t *testing.T) { + mock := setupTestDB(t) + + // The LEFT JOIN query returns all queue columns + NULL for activity_logs + // when no delegation row exists. + mock.ExpectQuery(`SELECT\s+q\.id,\s+q\.workspace_id,\s+q\.status,\s+q\.priority,\s+q\.attempts,\s+q\.last_error,\s+q\.enqueued_at::text,\s+q\.dispatched_at::text,\s+q\.completed_at::text,\s+q\.expires_at::text,\s+al\.response_body::text\s+FROM a2a_queue q\s+LEFT JOIN activity_logs al`). + WithArgs("queue-ok-1"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "workspace_id", "status", "priority", "attempts", + "last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at", + "response_body", + }).AddRow( + "queue-ok-1", "ws-1", "queued", 50, 1, + nil, "2026-05-16T10:00:00Z", nil, nil, "2026-05-16T12:00:00Z", + nil, + )) + + qs, err := QueueStatusByID(context.Background(), "queue-ok-1") + if err != nil { + t.Fatalf("QueueStatusByID() error = %v; want nil", err) + } + if qs.ID != "queue-ok-1" { + t.Errorf("ID = %q; want queue-ok-1", qs.ID) + } + if qs.WorkspaceID != "ws-1" { + t.Errorf("WorkspaceID = %q; want ws-1", qs.WorkspaceID) + } + if qs.Status != "queued" { + t.Errorf("Status = %q; want queued", qs.Status) + } + if qs.Priority != 50 { + t.Errorf("Priority = %d; want 50", qs.Priority) + } + if qs.Attempts != 1 { + t.Errorf("Attempts = %d; want 1", qs.Attempts) + } + if qs.LastError != nil { + t.Errorf("LastError = %v; want nil", qs.LastError) + } + if qs.EnqueuedAt != "2026-05-16T10:00:00Z" { + t.Errorf("EnqueuedAt = %q; want 2026-05-16T10:00:00Z", qs.EnqueuedAt) + } + if qs.DispatchedAt != nil { + t.Errorf("DispatchedAt = %v; want nil", qs.DispatchedAt) + } + if qs.CompletedAt != nil { + t.Errorf("CompletedAt = %v; want nil", qs.CompletedAt) + } + if *qs.ExpiresAt != "2026-05-16T12:00:00Z" { + t.Errorf("ExpiresAt = %v; want 2026-05-16T12:00:00Z", qs.ExpiresAt) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// TestQueueStatusByID_CompletedWithResponse verifies that a completed queue item +// populates ResponseBody from the LEFT JOINed activity_logs row. +func TestQueueStatusByID_CompletedWithResponse(t *testing.T) { + mock := setupTestDB(t) + + respBody := `{"result":"done"}` + mock.ExpectQuery(`SELECT\s+q\.id`). + WithArgs("queue-done-1"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "workspace_id", "status", "priority", "attempts", + "last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at", + "response_body", + }).AddRow( + "queue-done-1", "ws-1", "completed", 50, 1, + nil, "2026-05-16T10:00:00Z", "2026-05-16T10:01:00Z", "2026-05-16T10:02:00Z", nil, + respBody, + )) + + qs, err := QueueStatusByID(context.Background(), "queue-done-1") + if err != nil { + t.Fatalf("QueueStatusByID() error = %v; want nil", err) + } + if qs.Status != "completed" { + t.Errorf("Status = %q; want completed", qs.Status) + } + if qs.ResponseBody == nil { + t.Fatal("ResponseBody = nil; want non-nil for completed item") + } + var resp map[string]interface{} + if err := json.Unmarshal(qs.ResponseBody, &resp); err != nil { + t.Fatalf("ResponseBody not valid JSON: %v", err) + } + if resp["result"] != "done" { + t.Errorf("ResponseBody result = %v; want done", resp["result"]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// TestQueueStatusByID_ErrNoRows returns sql.ErrNoRows when the queue ID doesn't exist. +func TestQueueStatusByID_ErrNoRows(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT\s+q\.id`). + WithArgs("queue-missing"). + WillReturnError(sql.ErrNoRows) + + _, err := QueueStatusByID(context.Background(), "queue-missing") + if !errors.Is(err, sql.ErrNoRows) { + t.Errorf("QueueStatusByID() error = %v; want sql.ErrNoRows", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// TestQueueStatusByID_QueryError propagates DB errors as-is. +func TestQueueStatusByID_QueryError(t *testing.T) { + mock := setupTestDB(t) + + mock.ExpectQuery(`SELECT\s+q\.id`). + WithArgs("queue-err"). + WillReturnError(errors.New("connection refused")) + + _, err := QueueStatusByID(context.Background(), "queue-err") + if err == nil { + t.Fatal("QueueStatusByID() error = nil; want non-nil") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +// ─── GetA2AQueueStatus (HTTP handler) ───────────────────────────────────── + +func newGetA2AQueueStatusHarness(t *testing.T) (sqlmock.Sqlmock, *httptest.ResponseRecorder, *gin.Context) { + mock := setupTestDB(t) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + return mock, w, c +} + +func TestGetA2AQueueStatus_MissingQueueID_Returns400(t *testing.T) { + _, w, c := newGetA2AQueueStatusHarness(t) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: ""}} + c.Request = httptest.NewRequest("GET", "/", nil) + + h := newHandlerWithTestDeps(t) + h.GetA2AQueueStatus(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetA2AQueueStatus_NoIdentity_Returns404(t *testing.T) { + _, w, c := newGetA2AQueueStatusHarness(t) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-123"}} + c.Request = httptest.NewRequest("GET", "/", nil) + + h := newHandlerWithTestDeps(t) + h.GetA2AQueueStatus(c) + + // Returns 404 (not 401) per the existence-non-inference policy. + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGetA2AQueueStatus_QueueNotFound_Returns404(t *testing.T) { + mock, w, c := newGetA2AQueueStatusHarness(t) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-404"}} + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.Header.Set("X-Workspace-ID", "ws-1") + + mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`). + WithArgs("q-404"). + WillReturnError(sql.ErrNoRows) + + h := newHandlerWithTestDeps(t) + h.GetA2AQueueStatus(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestGetA2AQueueStatus_UnauthorizedCaller_Returns404(t *testing.T) { + mock, w, c := newGetA2AQueueStatusHarness(t) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-unauth"}} + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.Header.Set("X-Workspace-ID", "ws-wrong") + + mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`). + WithArgs("q-unauth"). + WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}). + AddRow("ws-caller-a", "ws-target-b")) + + h := newHandlerWithTestDeps(t) + h.GetA2AQueueStatus(c) + + // Returns 404 per the existence-non-inference policy. + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestGetA2AQueueStatus_AuthorizedAsTarget_Success(t *testing.T) { + mock, w, c := newGetA2AQueueStatusHarness(t) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-ok"}} + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.Header.Set("X-Workspace-ID", "ws-target") + + mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`). + WithArgs("q-ok"). + WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}). + AddRow("ws-caller", "ws-target")) + + mock.ExpectQuery(`SELECT\s+q\.id`). + WithArgs("q-ok"). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "workspace_id", "status", "priority", "attempts", + "last_error", "enqueued_at", "dispatched_at", "completed_at", "expires_at", + "response_body", + }).AddRow( + "q-ok", "ws-target", "queued", 50, 1, + nil, "2026-05-16T10:00:00Z", nil, nil, nil, + nil, + )) + + h := newHandlerWithTestDeps(t) + h.GetA2AQueueStatus(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var qs QueueStatus + if err := json.Unmarshal(w.Body.Bytes(), &qs); err != nil { + t.Fatalf("body parse: %v", err) + } + if qs.ID != "q-ok" { + t.Errorf("queue_id = %q; want q-ok", qs.ID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestGetA2AQueueStatus_QueueRowLookupError_Returns500(t *testing.T) { + mock, w, c := newGetA2AQueueStatusHarness(t) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-lookup-err"}} + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.Header.Set("X-Workspace-ID", "ws-1") + + mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`). + WithArgs("q-lookup-err"). + WillReturnError(errors.New("connection refused")) + + h := newHandlerWithTestDeps(t) + h.GetA2AQueueStatus(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +} + +func TestGetA2AQueueStatus_StatusFetchError_Returns500(t *testing.T) { + mock, w, c := newGetA2AQueueStatusHarness(t) + c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "queue_id", Value: "q-status-err"}} + c.Request = httptest.NewRequest("GET", "/", nil) + c.Request.Header.Set("X-Workspace-ID", "ws-1") + + mock.ExpectQuery(`SELECT caller_id, workspace_id FROM a2a_queue WHERE id = \$1`). + WithArgs("q-status-err"). + WillReturnRows(sqlmock.NewRows([]string{"caller_id", "workspace_id"}). + AddRow("ws-1", "ws-1")) + + mock.ExpectQuery(`SELECT\s+q\.id`). + WithArgs("q-status-err"). + WillReturnError(errors.New("connection refused")) + + h := newHandlerWithTestDeps(t) + h.GetA2AQueueStatus(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unmet: %v", err) + } +}