diff --git a/canvas/src/components/__tests__/Toolbar.a11y.test.tsx b/canvas/src/components/__tests__/Toolbar.a11y.test.tsx
new file mode 100644
index 000000000..587a89c2d
--- /dev/null
+++ b/canvas/src/components/__tests__/Toolbar.a11y.test.tsx
@@ -0,0 +1,422 @@
+// @vitest-environment jsdom
+/**
+ * Toolbar WCAG 2.1 AA accessibility tests.
+ *
+ * 27 test cases covering:
+ * - aria-expanded on help button reflects popover open/close state
+ * - aria-label on all icon-only buttons (A2A toggle, Search, Help, Audit trail)
+ * - aria-pressed on A2A topology toggle (reflects store state)
+ * - Help popover dialog: role="dialog", aria-label="Shortcuts and tips", aria-modal="false"
+ * - Close button inside popover: aria-label="Close help dialog"
+ * - aria-hidden suppression on decorative elements (logo alt, status dots, count text)
+ * - focus-visible:ring class presence on all interactive toolbar buttons
+ * - Stop All / Restart Pending buttons: descriptive aria-label with workspace/task count
+ * - Escape key closes help popover and resets aria-expanded
+ * - Screen reader text exposure for workspace count
+ *
+ * Notes:
+ * - fireEvent.click dispatches non-bubbling events; React synthetic handlers need
+ * bubbling. Use act(() => { el.dispatchEvent(new MouseEvent("click", { bubbles: true })) })
+ * for reliable click simulation.
+ * - container from render() can become stale after re-renders. Re-query after state changes.
+ * - No @testing-library/jest-dom — uses getAttribute, className, classList, container.querySelector.
+ */
+import { afterEach, beforeEach, describe, expect, fireEvent, it, vi } from "vitest";
+import { act, cleanup, render, within } from "@testing-library/react";
+import React from "react";
+
+import { Toolbar } from "../Toolbar";
+
+// ── Mock targets ───────────────────────────────────────────────────────────────
+
+vi.mock("@/components/Toaster", () => ({
+ showToast: vi.fn(),
+}));
+
+vi.mock("@/components/ConfirmDialog", () => ({
+ ConfirmDialog: () => null,
+}));
+
+vi.mock("@/components/settings/SettingsButton", () => ({
+ SettingsButton: () => null,
+}));
+
+vi.mock("@/components/settings/SettingsPanel", () => ({
+ settingsGearRef: { current: null },
+}));
+
+vi.mock("@/components/ThemeToggle", () => ({
+ ThemeToggle: () => null,
+}));
+
+vi.mock("@/components/KeyboardShortcutsDialog", () => ({
+ KeyboardShortcutsDialog: ({ open }: { open: boolean; onClose: () => void }) =>
+ open ?
Shortcuts
: null,
+}));
+
+vi.mock("@/lib/design-tokens", () => ({
+ statusDotClass: (status: string) => {
+ const map: Record = {
+ online: "bg-emerald-400",
+ offline: "bg-zinc-500",
+ paused: "bg-indigo-400",
+ degraded: "bg-amber-400",
+ failed: "bg-red-400",
+ provisioning: "bg-sky-400",
+ };
+ return map[status] ?? "bg-zinc-500";
+ },
+}));
+
+vi.mock("@/lib/api", () => ({
+ api: { post: vi.fn(() => Promise.resolve()) },
+}));
+
+// ── Store mocks ───────────────────────────────────────────────────────────────
+
+const mockSetShowA2AEdges = vi.fn();
+const mockSetPanelTab = vi.fn();
+const mockSetSearchOpen = vi.fn();
+
+const makeNode = (
+ i: number,
+ status: "online" | "offline" | "failed" | "provisioning" = "online",
+ activeTasks = 0,
+ needsRestart = false,
+ parentId: string | null = null
+) => ({
+ id: `ws-${i}`,
+ data: {
+ name: `Workspace ${i}`,
+ role: "agent",
+ tier: 1,
+ status,
+ parentId,
+ activeTasks,
+ needsRestart,
+ },
+});
+
+const storeState = {
+ nodes: [] as ReturnType[],
+ wsStatus: "connected" as "connected" | "connecting" | "disconnected",
+ showA2AEdges: false,
+ selectedNodeId: null as string | null,
+ sidePanelWidth: 480,
+ setShowA2AEdges: mockSetShowA2AEdges,
+ setPanelTab: mockSetPanelTab,
+ setSearchOpen: mockSetSearchOpen,
+ selectedNodeIds: new Set(),
+};
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)),
+}));
+
+// ── Setup / teardown ─────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ mockSetShowA2AEdges.mockClear();
+ mockSetPanelTab.mockClear();
+ mockSetSearchOpen.mockClear();
+ storeState.nodes = [];
+ storeState.wsStatus = "connected";
+ storeState.showA2AEdges = false;
+ storeState.selectedNodeId = null;
+});
+
+afterEach(cleanup);
+
+// ── Helper: click an element and flush React state updates ─────────────────────
+// fireEvent.click dispatches non-bubbling events; React synthetic handlers need
+// bubbles:true. Wrapping in act() ensures state updates are flushed synchronously.
+function clickElement(el: HTMLElement | null) {
+ if (!el) throw new Error("clickElement: el is null");
+ act(() => {
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
+ });
+}
+
+// ─── aria-expanded on help button ───────────────────────────────────────────────
+
+describe("Toolbar a11y — aria-expanded on help button", () => {
+ it("help button has aria-expanded=false when popover is closed", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const helpBtn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ expect(helpBtn).toBeTruthy();
+ expect(helpBtn.getAttribute("aria-expanded")).toBe("false");
+ });
+
+ it("help button has aria-expanded=true after clicking it", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const helpBtn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ clickElement(helpBtn);
+ // Re-query after state change — container becomes stale after re-render
+ const btn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ expect(btn.getAttribute("aria-expanded")).toBe("true");
+ });
+
+ it("Escape key closes help popover and resets aria-expanded to false", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ // Open the popover
+ const helpBtn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ clickElement(helpBtn);
+ expect(container.querySelector('[role="dialog"]')).toBeTruthy();
+ // Press Escape — dispatched on document (Toolbar listens via window, which in
+ // jsdom is accessible via document.defaultView). Use dispatchEvent for reliability.
+ act(() => {
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
+ const btn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ expect(btn.getAttribute("aria-expanded")).toBe("false");
+ });
+});
+
+// ─── aria-label on icon-only buttons ───────────────────────────────────────────
+
+describe("Toolbar a11y — aria-label on icon-only buttons", () => {
+ it("A2A toggle button has aria-label", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /show a2a edges/i });
+ expect(btn).toBeTruthy();
+ expect(btn.getAttribute("aria-label")).toBeTruthy();
+ });
+
+ it("Audit trail button has aria-label", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /open audit trail/i });
+ expect(btn).toBeTruthy();
+ expect(btn.getAttribute("aria-label")).toBeTruthy();
+ });
+
+ it("Search button has aria-label", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /search workspaces/i });
+ expect(btn).toBeTruthy();
+ expect(btn.getAttribute("aria-label")).toBeTruthy();
+ });
+
+ it("Help button has aria-label", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /open shortcuts and tips/i });
+ expect(btn).toBeTruthy();
+ expect(btn.getAttribute("aria-label")).toBeTruthy();
+ });
+});
+
+// ─── aria-pressed on A2A toggle ─────────────────────────────────────────────────
+
+describe("Toolbar a11y — aria-pressed on A2A topology toggle", () => {
+ it("A2A button has aria-pressed=false when showA2AEdges is false", () => {
+ storeState.nodes = [makeNode(0)];
+ storeState.showA2AEdges = false;
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /show a2a edges/i });
+ expect(btn.getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("A2A button has aria-pressed=true when showA2AEdges is true", () => {
+ storeState.nodes = [makeNode(0)];
+ storeState.showA2AEdges = true;
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /hide a2a edges/i });
+ expect(btn).toBeTruthy();
+ expect(btn.getAttribute("aria-pressed")).toBe("true");
+ });
+});
+
+// ─── Help popover dialog ARIA ──────────────────────────────────────────────────
+
+describe("Toolbar a11y — help popover dialog", () => {
+ it("help popover has role=dialog", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const helpBtn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ clickElement(helpBtn);
+ expect(container.querySelector('[role="dialog"]')).toBeTruthy();
+ });
+
+ it("help popover has aria-label='Shortcuts and tips'", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const helpBtn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ clickElement(helpBtn);
+ const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
+ expect(dialog.getAttribute("aria-label")).toBe("Shortcuts and tips");
+ });
+
+ it("help popover has aria-modal=false", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const helpBtn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ clickElement(helpBtn);
+ const dialog = container.querySelector('[role="dialog"]') as HTMLElement;
+ expect(dialog.getAttribute("aria-modal")).toBe("false");
+ });
+
+ it("close button inside popover has aria-label='Close help dialog'", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const helpBtn = container.querySelector('[aria-label="Open shortcuts and tips"]') as HTMLElement;
+ clickElement(helpBtn);
+ const closeBtn = container.querySelector('[aria-label="Close help dialog"]') as HTMLElement;
+ expect(closeBtn).toBeTruthy();
+ });
+});
+
+// ─── aria-hidden on decorative elements ───────────────────────────────────────
+
+describe("Toolbar a11y — aria-hidden on decorative elements", () => {
+ it("logo img has alt text (accessibility benefit — not aria-hidden by default)", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const img = withinThis.getByRole("img", { name: /molecule ai/i });
+ expect(img).toBeTruthy();
+ expect(img.getAttribute("alt")).toBe("Molecule AI");
+ });
+
+ it("status dot in StatusPill is aria-hidden", () => {
+ storeState.nodes = [makeNode(0, "online")];
+ const { container } = render();
+ const withinThis = within(container);
+ const pills = withinThis.getAllByLabelText(/1 online/i);
+ const container$ = pills[0];
+ const dots = container$.querySelectorAll('[aria-hidden="true"]');
+ expect(dots.length).toBeGreaterThan(0);
+ });
+
+ it("status count text in StatusPill is aria-hidden (decorative)", () => {
+ storeState.nodes = [makeNode(0, "online")];
+ const { container } = render();
+ const withinThis = within(container);
+ const pills = withinThis.getAllByLabelText(/1 online/i);
+ const container$ = pills[0];
+ const hiddenSpans = container$.querySelectorAll('[aria-hidden="true"]');
+ expect(hiddenSpans.length).toBeGreaterThan(0);
+ });
+});
+
+// ─── focus-visible:ring on interactive buttons ────────────────────────────────
+
+describe("Toolbar a11y — focus-visible:ring on interactive buttons", () => {
+ it("A2A toggle button has focus-visible:ring class", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /show a2a edges/i });
+ expect(btn.className).toMatch(/focus-visible:ring/);
+ });
+
+ it("Audit trail button has focus-visible:ring class", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /open audit trail/i });
+ expect(btn.className).toMatch(/focus-visible:ring/);
+ });
+
+ it("Search button has focus-visible:ring class", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /search workspaces/i });
+ expect(btn.className).toMatch(/focus-visible:ring/);
+ });
+
+ it("Help button has focus-visible:ring class", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /open shortcuts and tips/i });
+ expect(btn.className).toMatch(/focus-visible:ring/);
+ });
+});
+
+// ─── Stop All / Restart Pending aria-label ────────────────────────────────────
+
+describe("Toolbar a11y — Stop All / Restart Pending aria-label", () => {
+ it("Stop All button has descriptive aria-label including task count", () => {
+ // counts.activeTasks = number of nodes with activeTasks > 0; one node with
+ // 3 tasks = 1 node with active tasks = "(1 active)".
+ storeState.nodes = [makeNode(0, "online", 3)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /stop all running tasks/i });
+ expect(btn).toBeTruthy();
+ expect(btn.getAttribute("aria-label")).toMatch(/\(1 active\)/);
+ });
+
+ it("Restart Pending button has descriptive aria-label including workspace count", () => {
+ storeState.nodes = [makeNode(0, "online", 0, true)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /restart/i });
+ expect(btn).toBeTruthy();
+ expect(btn.getAttribute("aria-label")).toMatch(/restart 1 workspace/i);
+ });
+
+ it("Restart Pending aria-label uses singular when one workspace needs restart", () => {
+ storeState.nodes = [makeNode(0, "online", 0, true)];
+ const { container } = render();
+ const withinThis = within(container);
+ const btn = withinThis.getByRole("button", { name: /restart/i });
+ expect(btn).toBeTruthy();
+ // aria-label must contain "1 workspace" not "1 workspaces"
+ expect(btn.getAttribute("aria-label")).toMatch(/1 workspace/);
+ expect(btn.getAttribute("aria-label")).not.toMatch(/1 workspaces/);
+ });
+});
+
+// ─── Screen reader text exposure ────────────────────────────────────────────────
+
+describe("Toolbar a11y — screen reader text exposure", () => {
+ it("workspace count is rendered as visible text (not aria-hidden)", () => {
+ storeState.nodes = [makeNode(0)];
+ const { container } = render();
+ const withinThis = within(container);
+ const text = withinThis.getByText(/1 workspaces?/);
+ expect(text).toBeTruthy();
+ // The text is NOT aria-hidden — verify by checking its parent chain.
+ const el = text instanceof HTMLElement ? text : (text as Element);
+ expect(el.closest('[aria-hidden="true"]')).toBeNull();
+ });
+
+ it("wsStatus 'connected' renders 'Live' as accessible text", () => {
+ storeState.nodes = [makeNode(0)];
+ storeState.wsStatus = "connected";
+ const { container } = render();
+ const withinThis = within(container);
+ expect(withinThis.getByText("Live")).toBeTruthy();
+ });
+
+ it("wsStatus 'connecting' renders 'Reconnecting' as accessible text", () => {
+ storeState.nodes = [makeNode(0)];
+ storeState.wsStatus = "connecting";
+ const { container } = render();
+ const withinThis = within(container);
+ expect(withinThis.getByText("Reconnecting")).toBeTruthy();
+ });
+
+ it("wsStatus 'disconnected' renders 'Offline' as accessible text", () => {
+ storeState.nodes = [makeNode(0)];
+ storeState.wsStatus = "disconnected";
+ const { container } = render();
+ const withinThis = within(container);
+ expect(withinThis.getByText("Offline")).toBeTruthy();
+ });
+});
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..2959273a0
--- /dev/null
+++ b/workspace-server/internal/handlers/workspace_abilities_test.go
@@ -0,0 +1,223 @@
+package handlers
+
+import (
+ "bytes"
+ "context"
+ "database/sql"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/gin-gonic/gin"
+)
+
+// validUUID is a properly formatted test UUID: 8-4-4-4-12 hex chars.
+const validUUID = "dddddddd-0001-0001-0001-000000000001"
+
+// buildPatchCtx creates a gin.Context wired for PATCH /workspaces/:id/abilities,
+// with c.Params["id"] set so the handler's c.Param("id") call resolves correctly.
+func buildPatchCtx(id, body string) (*gin.Context, *httptest.ResponseRecorder) {
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ req := httptest.NewRequest(http.MethodPatch, "/workspaces/"+id+"/abilities", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ c.Request = req.WithContext(context.Background())
+ // Wire the :id param so c.Param("id") resolves.
+ c.Params = gin.Params{{Key: "id", Value: id}}
+ return c, w
+}
+
+// ─── Validation ───────────────────────────────────────────────────────────────
+
+func TestPatchAbilities_InvalidWorkspaceID(t *testing.T) {
+ c, w := buildPatchCtx("not-a-uuid", `{"broadcast_enabled":true}`)
+ PatchAbilities(c)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestPatchAbilities_InvalidJSON(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, "not json")
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}
+
+func TestPatchAbilities_NoAbilityFields(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, `{}`)
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}
+
+// ─── Workspace not found ───────────────────────────────────────────────────────
+
+func TestPatchAbilities_WorkspaceNotFound(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, `{"broadcast_enabled":true}`)
+
+ // SELECT EXISTS returns false — workspace not found or removed.
+ mock.ExpectQuery("SELECT EXISTS").
+ WithArgs(validUUID).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}
+
+func TestPatchAbilities_WorkspaceExistsQueryError(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, `{"broadcast_enabled":true}`)
+
+ // SELECT EXISTS fails — handler treats it as not found (|| !exists).
+ mock.ExpectQuery("SELECT EXISTS").
+ WithArgs(validUUID).
+ WillReturnError(sql.ErrConnDone)
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}
+
+// ─── Success paths ─────────────────────────────────────────────────────────────
+
+func TestPatchAbilities_Success_BroadcastEnabled(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, `{"broadcast_enabled":true}`)
+
+ mock.ExpectQuery("SELECT EXISTS").
+ WithArgs(validUUID).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
+ WithArgs(validUUID, true).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}
+
+func TestPatchAbilities_Success_TalkToUserEnabled(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, `{"talk_to_user_enabled":false}`)
+
+ mock.ExpectQuery("SELECT EXISTS").
+ WithArgs(validUUID).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
+ WithArgs(validUUID, false).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}
+
+func TestPatchAbilities_Success_BothFields(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, `{"broadcast_enabled":true,"talk_to_user_enabled":false}`)
+
+ mock.ExpectQuery("SELECT EXISTS").
+ WithArgs(validUUID).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
+ WithArgs(validUUID, true).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
+ WithArgs(validUUID, false).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}
+
+// ─── DB error paths ─────────────────────────────────────────────────────────────
+
+func TestPatchAbilities_DBErrorOnBroadcastUpdate(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, `{"broadcast_enabled":true}`)
+
+ mock.ExpectQuery("SELECT EXISTS").
+ WithArgs(validUUID).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
+ WithArgs(validUUID, true).
+ WillReturnError(sql.ErrConnDone)
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusInternalServerError {
+ t.Errorf("want 500, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}
+
+func TestPatchAbilities_DBErrorOnTalkToUserUpdate(t *testing.T) {
+ mock := setupTestDB(t)
+ c, w := buildPatchCtx(validUUID, `{"broadcast_enabled":false,"talk_to_user_enabled":true}`)
+
+ mock.ExpectQuery("SELECT EXISTS").
+ WithArgs(validUUID).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ // Broadcast update succeeds; talk_to_user update fails.
+ mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
+ WithArgs(validUUID, false).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
+ WithArgs(validUUID, true).
+ WillReturnError(sql.ErrConnDone)
+
+ PatchAbilities(c)
+
+ if w.Code != http.StatusInternalServerError {
+ t.Errorf("want 500, got %d: %s", w.Code, w.Body.String())
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Errorf("unmet mock expectations: %v", err)
+ }
+}