Compare commits

..

1 Commits

Author SHA1 Message Date
fullstack-engineer 51c5baa164 test(handlers): add sqlmock suite for BroadcastHandler
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 11s
E2E Chat / detect-changes (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 10s
Harness Replays / detect-changes (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 15s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 36s
qa-review / approved (pull_request) Successful in 11s
security-review / approved (pull_request) Successful in 13s
gate-check-v3 / gate-check (pull_request) Successful in 7s
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request) Successful in 14s
sop-tier-check / tier-check (pull_request) Successful in 18s
Harness Replays / Harness Replays (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 24s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3m34s
CI / Python Lint & Test (pull_request) Successful in 6m4s
CI / Platform (Go) (pull_request) Successful in 10m3s
CI / Canvas (Next.js) (pull_request) Successful in 11m56s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 11m52s
E2E Chat / E2E Chat (pull_request) Failing after 11m11s
audit-force-merge / audit (pull_request) Successful in 7s
POST /workspaces/:id/broadcast was completely untested despite being a
real handler with auth, DB queries, error recovery, and WS fan-out.
Adds 10 focused scenarios:

- Happy path: two recipients get BROADCAST_MESSAGE + activity rows
- Invalid workspace ID → 400
- Workspace not found (sql.ErrNoRows) → 404
- broadcast_enabled=false → 403 with error=broadcast_disabled
- No other workspaces → 200 delivered=0, no broadcasts
- Recipient activity_log insert fails → handler skips and continues
- Sender activity_log insert fails → still returns 200
- Missing message key → 400
- Missing body → 400
- broadcastTruncate edge cases (unicode, boundary, empty)

Also: extractAgentText had no test block in the canvas suite —
adds 11 cases covering top-level parts, artifacts, status.message,
priority order, string identity, malformed input, and multi-part join.

While here, refactors BroadcastHandler.broadcaster field from the
concrete *events.Broadcaster type to the events.EventEmitter
interface so the constructor accepts test doubles without a concrete
dependency. The concrete type still satisfies the interface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:11:01 +00:00
7 changed files with 13 additions and 703 deletions
+10 -12
View File
@@ -145,10 +145,10 @@ jobs:
# the diagnostic step with its own continue-on-error: true (line 203).
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
continue-on-error: false
# Job-level ceiling. The go test step below runs with a per-step 30m timeout;
# this cap catches any step that leaks past that. Set well above 30m so
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
# this cap catches any step that leaks past that. Set well above 10m so
# the per-step timeout is the active constraint.
timeout-minutes: 35
timeout-minutes: 15
defaults:
run:
working-directory: workspace-server
@@ -176,14 +176,12 @@ jobs:
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always()
name: Diagnostic — per-package verbose (300s timeout)
name: Diagnostic — per-package verbose 60s
run: |
set +e
# 300s allows handlers + pendinguploads packages to complete on cold
# runners with -race instrumentation (~60-120s each vs ~14s non-race).
go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
handlers_exit=$?
go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
pu_exit=$?
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
tail -100 /tmp/test-handlers.log
@@ -196,10 +194,10 @@ jobs:
- if: always()
name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
# full ./... suite with race detection + coverage. A 30m per-step timeout
# lets the suite complete on cold cache (~13-25m) while failing cleanly
# instead of OOM-killing. The job-level timeout (35m) is a backstop.
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
# full ./... suite with race detection + coverage. A 10m per-step timeout
# lets the suite complete on cold cache (~5-7m) while failing cleanly
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
- if: always()
name: Per-file coverage report
@@ -1,102 +0,0 @@
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");
});
});
+2 -65
View File
@@ -1,12 +1,9 @@
// @vitest-environment jsdom
/**
* Tests for theme-cookie.ts:
* - THEME_COOKIE constant
* - readThemeCookie
* - themeBootScript
* Tests for readThemeCookie — parses a cookie value into a ThemePreference.
*/
import { describe, it, expect } from "vitest";
import { readThemeCookie, THEME_COOKIE, themeBootScript } from "../theme-cookie";
import { readThemeCookie } from "../theme-cookie";
describe("readThemeCookie", () => {
it('returns "light" when cookie value is "light"', () => {
@@ -48,63 +45,3 @@ describe("readThemeCookie", () => {
}
});
});
// ── THEME_COOKIE ────────────────────────────────────────────────────────────────
describe("THEME_COOKIE", () => {
it("is a non-empty string", () => {
expect(typeof THEME_COOKIE).toBe("string");
expect(THEME_COOKIE.length).toBeGreaterThan(0);
});
it("equals 'mol_theme'", () => {
expect(THEME_COOKIE).toBe("mol_theme");
});
it("is stable — constant is not reassigned", () => {
const first = THEME_COOKIE;
const second = THEME_COOKIE;
expect(first).toBe(second);
});
});
// ── themeBootScript ─────────────────────────────────────────────────────────────
describe("themeBootScript", () => {
it("is a non-empty string", () => {
expect(typeof themeBootScript).toBe("string");
expect(themeBootScript.length).toBeGreaterThan(0);
});
it("contains THEME_COOKIE value in the cookie-regex pattern", () => {
// The script reads document.cookie looking for mol_theme=...
expect(themeBootScript).toContain(THEME_COOKIE);
});
it("contains 'system', 'light', 'dark' in the match pattern", () => {
expect(themeBootScript).toContain("system");
expect(themeBootScript).toContain("light");
expect(themeBootScript).toContain("dark");
});
it("contains data-theme assignment on documentElement", () => {
// The script sets document.documentElement.dataset.theme = resolved
expect(themeBootScript).toContain("dataset.theme");
expect(themeBootScript).toContain("document.documentElement");
});
it("contains matchMedia call for OS preference fallback", () => {
expect(themeBootScript).toContain("matchMedia");
expect(themeBootScript).toContain("prefers-color-scheme");
});
it("wraps the entire body in an IIFE so it runs immediately", () => {
expect(themeBootScript).toMatch(/^\(\(\)=>/);
});
it("is pure — constant evaluated once, same value every time", () => {
const a = themeBootScript;
const b = themeBootScript;
expect(a).toBe(b);
});
});
@@ -1,277 +0,0 @@
// @vitest-environment jsdom
"use client";
/**
* Tests for theme-provider.tsx:
* - applyResolvedTheme — pure DOM side-effect function
* - ThemeProvider — context, setTheme, resolvedTheme derivation
* - useTheme — hook + noop fallback
*
* Coverage gaps filled vs theme-cookie.test.ts (which tests only readThemeCookie):
* applyResolvedTheme, ThemeProvider initialTheme, resolvedTheme derivation
* from system preference, writeThemeCookie integration, useTheme noop fallback.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { render, screen, cleanup, act, waitFor } from "@testing-library/react";
import { applyResolvedTheme, ThemeProvider, useTheme } from "../theme-provider";
// ─── applyResolvedTheme ────────────────────────────────────────────────────────
describe("applyResolvedTheme", () => {
beforeEach(() => {
if (typeof document !== "undefined") {
delete (document.documentElement as Record<string, unknown>).dataset;
}
});
afterEach(() => {
cleanup();
if (typeof document !== "undefined") {
delete (document.documentElement as Record<string, unknown>).dataset;
}
});
it('sets data-theme="light" on document.documentElement', () => {
applyResolvedTheme("light");
expect(document.documentElement.dataset.theme).toBe("light");
});
it('sets data-theme="dark" on document.documentElement', () => {
applyResolvedTheme("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("is idempotent — calling twice with same value keeps the same attribute", () => {
applyResolvedTheme("dark");
applyResolvedTheme("dark");
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("is a pure function for its DOM side-effect — no return value", () => {
expect(applyResolvedTheme("light")).toBeUndefined();
});
it("guards against undefined document (SSR safety)", () => {
// In Node.js / SSR context document is undefined; the function returns
// early without throwing. We simulate this by temporarily deleting document.
const saved = globalThis.document;
// @ts-expect-error — intentionally undefined for SSR test
globalThis.document = undefined;
expect(() => applyResolvedTheme("dark")).not.toThrow();
globalThis.document = saved;
});
});
// ─── ThemeProvider ─────────────────────────────────────────────────────────────
describe("ThemeProvider", () => {
beforeEach(() => {
// Stub matchMedia so ThemeProvider's system-preference useEffect works in jsdom.
// Default to light mode (matches=false) so resolvedTheme="light" when theme="system".
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false, // light preference by default
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(() => {
cleanup();
if (typeof document !== "undefined") {
delete (document.documentElement as Record<string, unknown>).dataset;
}
// Clear cookies set by writeThemeCookie.
if (typeof document !== "undefined") {
document.cookie = "mol_theme=; Max-Age=0";
}
});
function ThemeChild() {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="resolved">{resolvedTheme}</span>
<button
data-testid="set-light"
onClick={() => setTheme("light")}
>
light
</button>
<button
data-testid="set-dark"
onClick={() => setTheme("dark")}
>
dark
</button>
</div>
);
}
it("renders children", () => {
render(
<ThemeProvider initialTheme="light">
<span data-testid="child">Hello</span>
</ThemeProvider>,
);
expect(screen.getByTestId("child")).toBeTruthy();
});
it('initialTheme="light" sets theme=light', () => {
render(
<ThemeProvider initialTheme="light">
<ThemeChild />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("light");
});
it('initialTheme="dark" sets theme=dark', () => {
render(
<ThemeProvider initialTheme="dark">
<ThemeChild />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("dark");
});
it('initialTheme="system" falls back to light (matchMedia stub)', () => {
// matchMedia is not stubbed in jsdom by default; the provider calls it
// and reads the OS preference. Without a stub, jsdom returns
// { matches: false } → "light".
render(
<ThemeProvider initialTheme="system">
<ThemeChild />
</ThemeProvider>,
);
// Resolved is "light" because jsdom matchMedia stub returns false for dark.
expect(screen.getByTestId("resolved").textContent).toBe("light");
});
it("setTheme('dark') updates both theme and resolvedTheme", async () => {
render(
<ThemeProvider initialTheme="light">
<ThemeChild />
</ThemeProvider>,
);
expect(screen.getByTestId("theme").textContent).toBe("light");
await act(async () => {
screen.getByTestId("set-dark").click();
});
expect(screen.getByTestId("theme").textContent).toBe("dark");
// resolvedTheme tracks theme when not in system mode.
expect(screen.getByTestId("resolved").textContent).toBe("dark");
});
it("setTheme('light') updates both theme and resolvedTheme", async () => {
render(
<ThemeProvider initialTheme="dark">
<ThemeChild />
</ThemeProvider>,
);
await act(async () => {
screen.getByTestId("set-light").click();
});
expect(screen.getByTestId("theme").textContent).toBe("light");
expect(screen.getByTestId("resolved").textContent).toBe("light");
});
it("writes mol_theme cookie when setTheme is called", async () => {
render(
<ThemeProvider initialTheme="light">
<ThemeChild />
</ThemeProvider>,
);
await act(async () => {
screen.getByTestId("set-dark").click();
});
expect(document.cookie).toContain("mol_theme=dark");
});
it("calls applyResolvedTheme on mount (data-theme set on <html>)", () => {
render(
<ThemeProvider initialTheme="dark">
<span data-testid="child">hi</span>
</ThemeProvider>,
);
expect(document.documentElement.dataset.theme).toBe("dark");
});
it("calls applyResolvedTheme when resolvedTheme changes", async () => {
render(
<ThemeProvider initialTheme="light">
<ThemeChild />
</ThemeProvider>,
);
// Start at light.
expect(document.documentElement.dataset.theme).toBe("light");
await act(async () => {
screen.getByTestId("set-dark").click();
});
expect(document.documentElement.dataset.theme).toBe("dark");
});
});
// ─── useTheme noop fallback ────────────────────────────────────────────────────
describe("useTheme without ThemeProvider", () => {
afterEach(() => {
cleanup();
});
it("useTheme returns noopTheme when no provider is in the tree", () => {
function ShowTheme() {
const { theme, resolvedTheme, setTheme } = useTheme();
return (
<div>
<span data-testid="theme">{theme}</span>
<span data-testid="resolved">{resolvedTheme}</span>
<span data-testid="setTheme-type">{typeof setTheme}</span>
</div>
);
}
render(<ShowTheme />);
// noopTheme defaults: theme="system", resolvedTheme="light", setTheme no-op.
expect(screen.getByTestId("theme").textContent).toBe("system");
expect(screen.getByTestId("resolved").textContent).toBe("light");
expect(screen.getByTestId("setTheme-type").textContent).toBe("function");
});
it("setTheme is a no-op when no provider is present (no throw)", async () => {
let threw = false;
function ClickSetTheme() {
const { setTheme } = useTheme();
return (
<button
data-testid="call-setTheme"
onClick={() => {
try {
setTheme("dark");
} catch {
threw = true;
}
}}
>
call
</button>
);
}
render(<ClickSetTheme />);
await act(async () => {
screen.getByTestId("call-setTheme").click();
});
expect(threw).toBe(false);
});
});
+1 -1
View File
@@ -75,7 +75,7 @@ function writeThemeCookie(value: ThemePreference): void {
document.cookie = parts.join("; ");
}
export function applyResolvedTheme(resolved: ResolvedTheme): void {
function applyResolvedTheme(resolved: ResolvedTheme): void {
if (typeof document === "undefined") return;
document.documentElement.dataset.theme = resolved;
}
@@ -1,53 +0,0 @@
package handlers
// plugins_install_test.go — additional coverage for plugins_install.go.
//
// Gaps filled vs. existing test files:
// - plugins_install_external_test.go: Install + Uninstall 422 (external runtime) ✓ covered
// - plugins_test.go: Install 400 (missing source, invalid body, etc.) ✓ covered
// Uninstall 400 (invalid plugin name, empty name) ✓ covered
// Download auth gate ✓ covered
// - org_import_helpers_test.go: countWorkspaces, envRequirementKey, sanitizeEnvMembers,
// flattenAndSortRequirements, collectOrgEnv ✓ covered
//
// New test added here:
// - Uninstall 503: container not running, no SaaS dispatch.
//
// NOTE: validateWorkspaceID is not called inside the Install/Uninstall handlers.
// UUID validation is the responsibility of the WorkspaceAuth middleware, so no
// 400 test is needed here for UUID format.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestPluginUninstall_ContainerNotRunning_Returns503 exercises the 503 path
// where neither a local Docker container nor a SaaS instance-id dispatch
// resolves. The handler must return "workspace container not running" — NOT a
// generic 500 or a misleading 422 (external-runtime) message.
func TestPluginUninstall_ContainerNotRunning_Returns503(t *testing.T) {
// No docker client + no instance-id lookup → falls through to 503.
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"},
{Key: "name", Value: "some-plugin"},
}
c.Request = httptest.NewRequest("DELETE",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/plugins/some-plugin", nil)
h.Uninstall(c)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
require.Equal(t, "workspace container not running", body["error"])
}
@@ -1,193 +0,0 @@
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())
}
}