test(platform+canvas): PatchAbilities handler + Toolbar WCAG a11y coverage #1342
@@ -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 ? <div role="dialog" data-testid="shortcuts-dialog">Shortcuts</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/design-tokens", () => ({
|
||||
statusDotClass: (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
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<typeof makeNode>[],
|
||||
wsStatus: "connected" as "connected" | "connecting" | "disconnected",
|
||||
showA2AEdges: false,
|
||||
selectedNodeId: null as string | null,
|
||||
sidePanelWidth: 480,
|
||||
setShowA2AEdges: mockSetShowA2AEdges,
|
||||
setPanelTab: mockSetPanelTab,
|
||||
setSearchOpen: mockSetSearchOpen,
|
||||
selectedNodeIds: new Set<string>(),
|
||||
};
|
||||
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
// 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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
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(<Toolbar />);
|
||||
const withinThis = within(container);
|
||||
expect(withinThis.getByText("Offline")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user