From bacaf6aacb953be01c60b5f88ca31f3ccaca0231 Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 18 May 2026 05:24:32 +0000 Subject: [PATCH] test: cover PatchAbilities handler and resolveWorkspaceName utility Go: - Add sqlmock test suite for PATCH /workspaces/:id/abilities (8 cases): invalid ID, empty body, workspace not found, set broadcast_enabled, set talk_to_user_enabled, both fields, update-failure paths Canvas: - Add vitest suite for resolveWorkspaceName (7 cases): hit, miss, nameless node, edge-case IDs, multiple-workspaces disambiguation, read-only guarantee Co-Authored-By: Claude Opus 4.7 --- .../__tests__/resolveWorkspaceName.test.ts | 102 +++++++++ .../handlers/workspace_abilities_test.go | 193 ++++++++++++++++++ 2 files changed, 295 insertions(+) create mode 100644 canvas/src/components/tabs/chat/__tests__/resolveWorkspaceName.test.ts create mode 100644 workspace-server/internal/handlers/workspace_abilities_test.go diff --git a/canvas/src/components/tabs/chat/__tests__/resolveWorkspaceName.test.ts b/canvas/src/components/tabs/chat/__tests__/resolveWorkspaceName.test.ts new file mode 100644 index 000000000..2f54f9e87 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/resolveWorkspaceName.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useCanvasStore } from "@/store/canvas"; +import { resolveWorkspaceName } from "../hooks/resolveWorkspaceName"; + +beforeEach(() => { + // Reset store to a clean slate between tests so node lookup is deterministic. + useCanvasStore.setState({ nodes: [] }); +}); + +describe("resolveWorkspaceName", () => { + it("returns the workspace name when a node with that ID exists", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "ws-alpha-001", + type: "workspace", + data: { name: "Alpha Agent" }, + position: { x: 0, y: 0 }, + }, + ], + }); + + expect(resolveWorkspaceName("ws-alpha-001")).toBe("Alpha Agent"); + }); + + it("falls back to the first 8 chars of the ID when no matching node exists", () => { + expect(resolveWorkspaceName("ws-zzz-not-found")).toBe("ws-zzz-n"); + }); + + it("falls back to the first 8 chars when the node exists but has no name", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "ws-no-name", + type: "workspace", + // data.name is deliberately absent + data: {}, + position: { x: 0, y: 0 }, + }, + ], + }); + + expect(resolveWorkspaceName("ws-no-name")).toBe("ws-no-na"); + }); + + it("returns the first 8 chars for a very short ID", () => { + expect(resolveWorkspaceName("ab")).toBe("ab"); + }); + + it("returns the first 8 chars when the ID is exactly 8 characters", () => { + // slice(0,8) of an 8-char string is the full string + const id = "12345678"; + expect(resolveWorkspaceName(id)).toBe(id); + }); + + it("picks the right node when multiple workspaces share a prefix", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "00000000-0000-0000-0000-000000000001", + type: "workspace", + data: { name: "Backend Agent" }, + position: { x: 0, y: 0 }, + }, + { + id: "00000000-0000-0000-0000-000000000002", + type: "workspace", + data: { name: "Frontend Agent" }, + position: { x: 100, y: 0 }, + }, + ], + }); + + expect(resolveWorkspaceName("00000000-0000-0000-0000-000000000002")).toBe( + "Frontend Agent" + ); + expect(resolveWorkspaceName("00000000-0000-0000-0000-000000000001")).toBe( + "Backend Agent" + ); + }); + + it("does not mutate store state between calls", () => { + useCanvasStore.setState({ + nodes: [ + { + id: "stable-id", + type: "workspace", + data: { name: "Stable Workspace" }, + position: { x: 0, y: 0 }, + }, + ], + }); + + resolveWorkspaceName("stable-id"); + resolveWorkspaceName("unknown-id"); + + // Store nodes must be unchanged — resolveWorkspaceName is read-only. + const nodes = useCanvasStore.getState().nodes; + expect(nodes).toHaveLength(1); + expect((nodes[0] as { id: string }).id).toBe("stable-id"); + }); +}); diff --git a/workspace-server/internal/handlers/workspace_abilities_test.go b/workspace-server/internal/handlers/workspace_abilities_test.go new file mode 100644 index 000000000..ea912b389 --- /dev/null +++ b/workspace-server/internal/handlers/workspace_abilities_test.go @@ -0,0 +1,193 @@ +package handlers + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/gin-gonic/gin" +) + +// patchReq builds a gin context for a PATCH request to /workspaces/:id/abilities. +func patchReq(id, body string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: id}} + c.Request = httptest.NewRequest("PATCH", "/workspaces/"+id+"/abilities", bytes.NewBufferString(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c.Request, w, c +} + +func TestPatchAbilities_InvalidWorkspaceID(t *testing.T) { + setupTestDB(t) + + // "not-a-uuid" fails validateWorkspaceID + _, w, c := patchReq("not-a-uuid", `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_EmptyBody(t *testing.T) { + setupTestDB(t) + id := "00000000-0000-0000-0000-000000000001" + + // Empty JSON object — no ability fields present + _, w, c := patchReq(id, `{}`) + PatchAbilities(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["error"] != "at least one ability field required" { + t.Errorf("expected 'at least one ability field required', got %v", resp["error"]) + } +} + +func TestPatchAbilities_WorkspaceNotFound(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000002" + + // SELECT EXISTS returns false (workspace does not exist) + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) + + _, w, c := patchReq(id, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_SetBroadcastEnabledTrue(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000003" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE broadcast_enabled = true + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + _, w, c := patchReq(id, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if resp["status"] != "updated" { + t.Errorf("expected status=updated, got %v", resp["status"]) + } +} + +func TestPatchAbilities_SetTalkToUserEnabledFalse(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000004" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE talk_to_user_enabled = false + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + _, w, c := patchReq(id, `{"talk_to_user_enabled":false}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_BothFields(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000005" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE broadcast_enabled = false + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, false). + WillReturnResult(sqlmock.NewResult(0, 1)) + + // UPDATE talk_to_user_enabled = true + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, true). + WillReturnResult(sqlmock.NewResult(0, 1)) + + _, w, c := patchReq(id, `{"broadcast_enabled":false,"talk_to_user_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_BroadcastUpdateFails(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000006" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE fails + mock.ExpectExec(`UPDATE workspaces SET broadcast_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, true). + WillReturnError(sql.ErrConnDone) + + _, w, c := patchReq(id, `{"broadcast_enabled":true}`) + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestPatchAbilities_TalkToUserUpdateFails(t *testing.T) { + mock := setupTestDB(t) + id := "00000000-0000-0000-0000-000000000007" + + // SELECT EXISTS → true + mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1 AND status != 'removed'\)`). + WithArgs(id). + WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true)) + + // UPDATE broadcast_enabled skipped (not in payload) + // UPDATE talk_to_user_enabled fails + mock.ExpectExec(`UPDATE workspaces SET talk_to_user_enabled = \$2, updated_at = now\(\) WHERE id = \$1`). + WithArgs(id, false). + WillReturnError(sql.ErrConnDone) + + _, w, c := patchReq(id, `{"talk_to_user_enabled":false}`) + PatchAbilities(c) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String()) + } +} -- 2.52.0