Compare commits

..

2 Commits

Author SHA1 Message Date
fullstack-engineer 2710e094b9 test(canvas): add explicit STATUS_CONFIG shape coverage
STATUS_CONFIG exports 7 status keys (online, offline, paused, degraded,
failed, provisioning, not_configured) with dot/glow/label/bar per entry.
The existing statusDotClass.test.ts covered .dot indirectly but left the
constant's full shape (label, glow, bar) untested. Add a dedicated
design-tokens.test.ts that asserts all keys exist, every entry has the
correct fields, and field values match the known tailwind tokens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 05:09:19 +00:00
fullstack-engineer 3c708b6aaa test: add coverage for PatchAbilities, BroadcastHandler, ListSources, and cssVar
Go (3 files, 23 tests):
  workspace_abilities_test.go (9): PatchAbilities — 400 (invalid UUID /
    empty body / invalid JSON), 404 (workspace not found / DB error),
    200 (update each ability independently and both together)

  workspace_broadcast_test.go (13): BroadcastHandler + broadcastTruncate —
    400 (invalid UUID / missing message), 404 (not found), 403 (disabled
    with hint), 500 (recipient query error), 200 (no recipients / one
    recipient / recipient insert fails / sender log fails)

  plugins_sources_test.go (1): ListSources — returns 200 with schemes
    array from the real plugin registry, stable across calls

Canvas (1 file, 4 tests):
  theme.test.ts: cssVar — all 23 ColorToken variants, purity,
    hyphenated tokens, style-prop usability

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 05:09:19 +00:00
10 changed files with 891 additions and 618 deletions
@@ -1,183 +0,0 @@
// @vitest-environment jsdom
//
// Behavioral coverage for the SkillsTab registry loading and source schemes
// flows. Two regressions this pins down:
//
// 1. Registry fetch timeout: when GET /plugins takes >10s the component
// used to silently swallow the error (console.warn only), making it
// indistinguishable from a genuinely empty registry. Now it surfaces
// a specific timeout error with a Retry button so the user can recover.
//
// 2. Source schemes fallback: GET /plugins/sources failure is silent
// (falls back to "local only" UX) rather than crashing the component.
// This test verifies the fallback works without breaking the rest of
// the UI.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup, fireEvent, act, waitFor } from "@testing-library/react";
import React from "react";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
Element.prototype.scrollIntoView = vi.fn();
});
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector({ setPanelTab: vi.fn() } as Record<string, unknown>),
),
{ getState: () => ({ setPanelTab: vi.fn() }) },
),
summarizeWorkspaceCapabilities: vi.fn(() => ({ skills: [], tools: [] })),
}));
vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
import { SkillsTab } from "../tabs/SkillsTab";
import { api } from "@/lib/api";
function makeData() {
return {
name: "Test WS",
status: "online",
tier: 1,
agentCard: null,
activeTasks: 0,
collapsed: false,
role: "agent",
lastErrorRate: 0,
lastSampleError: "",
url: "http://localhost:9000",
parentId: null,
currentTask: "",
runtime: "claude_code",
needsRestart: false,
budgetLimit: null,
};
}
const REGISTRY = [
{
name: "browser-automation",
version: "1.1.0",
description: "Browser automation + testing",
author: "molecule",
tags: ["browser", "playwright"],
skills: [],
runtimes: ["claude-code"],
},
];
// Stable spy reference so tests can re-configure mockImplementation
// without re-creating the spy (avoids any restoration ordering issues).
let getSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Restore previous spy before creating a new one so old implementations
// don't leak between tests.
if (getSpy) getSpy.mockRestore();
getSpy = vi.spyOn(api, "get");
// Polyfill: jsdom Element.scrollIntoView is undefined without a browser.
Element.prototype.scrollIntoView = vi.fn();
// Microtasks (promise rejections) need real timers to flush so React
// state updates are visible to waitFor polling.
vi.useRealTimers();
});
// ─── Registry-loading tests ──────────────────────────────────────────────────
describe("SkillsTab registry loading", () => {
it("shows a generic error when GET /plugins fails", async () => {
getSpy.mockImplementation((path: string) => {
if (path === "/plugins") return Promise.reject(new Error("503 Service Unavailable"));
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://"] });
return Promise.resolve(null);
});
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
// Expand the registry section to see the error div (showRegistry starts false).
// Note: auto-expand requires registry.length > 0, so it doesn't fire on errors.
// Match compact-empty test pattern: wait for pill to settle before clicking.
await waitFor(() => {
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
});
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
fireEvent.click(installBtn);
// Wait for the error div to appear inside the expanded registry.
await screen.findByText(/503 Service Unavailable/i);
});
it("Retry button re-fetches the registry after a generic error", async () => {
let attempt = 0;
getSpy.mockImplementation((path: string) => {
if (path === "/plugins") {
attempt++;
if (attempt === 1) return Promise.reject(new Error("server error"));
return Promise.resolve(REGISTRY);
}
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://"] });
return Promise.resolve(null);
});
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
// Expand the registry section to see the error div (showRegistry starts false).
await waitFor(() => {
expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
});
const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
fireEvent.click(installBtn);
// Wait for error state to appear.
await screen.findByText(/server error/i);
// Click Retry — force=true bypasses the in-flight gate so the
// stranded promise from the first attempt is ignored.
const retryBtn = await screen.findByRole("button", { name: /retry/i });
fireEvent.click(retryBtn);
// After retry succeeds, registry plugins appear.
await screen.findByText("browser-automation");
});
});
// ─── Source-schemes tests ────────────────────────────────────────────────────
describe("SkillsTab source schemes", () => {
it("loads source schemes from GET /plugins/sources without crashing", async () => {
getSpy.mockImplementation((path: string) => {
if (path === "/plugins") return Promise.resolve(REGISTRY);
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
if (path === "/plugins/sources") return Promise.resolve({ schemes: ["local://", "github://"] });
return Promise.resolve(null);
});
render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />);
await screen.findByText("browser-automation");
expect(getSpy).toHaveBeenCalledWith("/plugins/sources");
});
it("gracefully falls back when GET /plugins/sources fails", async () => {
// /plugins/sources rejects (non-fatal); /plugins and /workspaces/:id/plugins succeed.
getSpy.mockImplementation((path: string) => {
if (path === "/plugins") return Promise.resolve(REGISTRY);
if (path === `/workspaces/ws-1/plugins`) return Promise.resolve([]);
if (path === "/plugins/sources") return Promise.reject(new Error("server error"));
return Promise.resolve(null);
});
// Must not throw — the component catches this and falls back silently.
expect(() => render(<SkillsTab workspaceId="ws-1" data={makeData() as never} />))
.not.toThrow();
// The rest of the UI still works — registry loaded despite sources failure.
await screen.findByText("browser-automation");
});
});
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
/**
* Tests for design-tokens.ts constant exports.
*
* STATUS_CONFIG is tested here directly rather than inside
* statusDotClass.test.ts so the constant's full shape (dot, glow, label,
* bar per key) is explicitly asserted — not just indirectly via the
* statusDotClass helper that consumes its .dot field.
*/
import { describe, it, expect } from "vitest";
import { STATUS_CONFIG } from "../design-tokens";
const ALL_STATUS_KEYS = [
"online",
"offline",
"paused",
"degraded",
"failed",
"provisioning",
"not_configured",
] as const;
describe("STATUS_CONFIG", () => {
it("has exactly the expected status keys and no extras", () => {
const actual = Object.keys(STATUS_CONFIG).sort();
const expected = [...ALL_STATUS_KEYS].sort();
expect(actual).toEqual(expected);
});
it("every entry has dot, glow, label, and bar fields", () => {
for (const key of ALL_STATUS_KEYS) {
const entry = STATUS_CONFIG[key];
expect(entry, `entry for "${key}"`).toHaveProperty("dot");
expect(entry, `entry for "${key}"`).toHaveProperty("glow");
expect(entry, `entry for "${key}"`).toHaveProperty("label");
expect(entry, `entry for "${key}"`).toHaveProperty("bar");
}
});
it("dot, glow, label, bar are all non-empty strings", () => {
for (const key of ALL_STATUS_KEYS) {
const entry = STATUS_CONFIG[key];
for (const field of ["dot", "glow", "label", "bar"] as const) {
expect(typeof entry[field], `"${key}".${field}`).toBe("string");
// label must be non-empty; others may be empty (e.g. offline.glow = "").
if (field === "label") {
expect(entry[field].length, `"${key}".${field}`).toBeGreaterThan(0);
}
}
}
});
it('online: dot is emerald, glow is set, label is "Online"', () => {
expect(STATUS_CONFIG.online.dot).toBe("bg-emerald-400");
expect(STATUS_CONFIG.online.glow).toBe("shadow-emerald-400/50");
expect(STATUS_CONFIG.online.label).toBe("Online");
expect(STATUS_CONFIG.online.bar).toBe("from-emerald-500/20 to-transparent");
});
it('offline: dot is zinc, glow is empty, label is "Offline"', () => {
expect(STATUS_CONFIG.offline.dot).toBe("bg-zinc-500");
expect(STATUS_CONFIG.offline.glow).toBe("");
expect(STATUS_CONFIG.offline.label).toBe("Offline");
expect(STATUS_CONFIG.offline.bar).toBe("from-zinc-600/10 to-transparent");
});
it('paused: dot is indigo, label is "Paused"', () => {
expect(STATUS_CONFIG.paused.dot).toBe("bg-indigo-400");
expect(STATUS_CONFIG.paused.glow).toBe("");
expect(STATUS_CONFIG.paused.label).toBe("Paused");
});
it('degraded: dot is amber with glow, label is "Degraded"', () => {
expect(STATUS_CONFIG.degraded.dot).toBe("bg-amber-400");
expect(STATUS_CONFIG.degraded.glow).toBe("shadow-amber-400/50");
expect(STATUS_CONFIG.degraded.label).toBe("Degraded");
});
it('failed: dot is red with glow, label is "Failed"', () => {
expect(STATUS_CONFIG.failed.dot).toBe("bg-red-400");
expect(STATUS_CONFIG.failed.glow).toBe("shadow-red-400/50");
expect(STATUS_CONFIG.failed.label).toBe("Failed");
});
it('provisioning: dot is sky with pulse animation, label is "Starting"', () => {
expect(STATUS_CONFIG.provisioning.dot).toBe("bg-sky-400 motion-safe:animate-pulse");
expect(STATUS_CONFIG.provisioning.glow).toBe("shadow-sky-400/50");
expect(STATUS_CONFIG.provisioning.label).toBe("Starting");
});
it('not_configured: dot is amber-300 with glow, label is "Not configured"', () => {
expect(STATUS_CONFIG.not_configured.dot).toBe("bg-amber-300");
expect(STATUS_CONFIG.not_configured.glow).toBe("shadow-amber-300/50");
expect(STATUS_CONFIG.not_configured.label).toBe("Not configured");
});
it("is a frozen static map — same key always returns same object reference", () => {
for (const key of ALL_STATUS_KEYS) {
expect(STATUS_CONFIG[key]).toBe(STATUS_CONFIG[key]);
}
});
});
+60
View File
@@ -0,0 +1,60 @@
// @vitest-environment jsdom
/**
* Tests for theme.ts — cssVar() function and ColorToken type.
*/
import { describe, it, expect } from "vitest";
import { cssVar, type ColorToken } from "../theme";
describe("cssVar", () => {
it("wraps each known token in a var() reference", () => {
const tokens: ColorToken[] = [
"surface",
"surface-elevated",
"surface-sunken",
"surface-card",
"line",
"line-soft",
"ink",
"ink-mid",
"ink-soft",
"accent",
"accent-strong",
"warm",
"good",
"bad",
"bg",
"bg-elev",
"bg-card",
"line-strong",
"ink-mute",
"ink-dim",
"accent-dim",
"plasma",
"warn",
];
for (const token of tokens) {
expect(cssVar(token)).toBe(`var(--color-${token})`);
}
});
it("is a pure function — same token always returns same value", () => {
for (let i = 0; i < 5; i++) {
expect(cssVar("accent")).toBe("var(--color-accent)");
expect(cssVar("surface")).toBe("var(--color-surface)");
expect(cssVar("good")).toBe("var(--color-good)");
}
});
it("handles hyphenated tokens correctly", () => {
expect(cssVar("surface-elevated")).toBe("var(--color-surface-elevated)");
expect(cssVar("line-soft")).toBe("var(--color-line-soft)");
expect(cssVar("ink-mute")).toBe("var(--color-ink-mute)");
});
it("produces a value usable as an inline style prop value", () => {
const result = cssVar("accent");
expect(typeof result).toBe("string");
expect(result.startsWith("var(--color-")).toBe(true);
expect(result.endsWith(")")).toBe(true);
});
});
@@ -214,7 +214,7 @@ func strDefault(m map[string]interface{}, key, fallback string) string {
// inputs. Transient daemon errors are logged distinctly so triage doesn't
// confuse a flaky daemon with a stopped container.
func (h *PluginsHandler) findRunningContainer(ctx context.Context, workspaceID string) string {
name, err := provisioner.RunningContainerNameFunc(ctx, h.docker, workspaceID)
name, err := provisioner.RunningContainerName(ctx, h.docker, workspaceID)
if err != nil {
log.Printf("plugins: docker inspect transient error for %s: %v (treating as not-running for this request)", workspaceID, err)
return ""
@@ -66,11 +66,9 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
if !ok {
return true
}
// Pkg.Func form: provisioner.RunningContainerNameFunc(...)
// Uses the pluggable wrapper, not the raw function — tests swap the
// wrapper so ListInstalled can be tested without a real Docker client.
// Pkg.Func form: provisioner.RunningContainerName(...)
if pkgIdent, ok := sel.X.(*ast.Ident); ok {
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerNameFunc" {
if pkgIdent.Name == "provisioner" && sel.Sel.Name == "RunningContainerName" {
callsRunningContainerName = true
}
}
@@ -85,7 +83,7 @@ func TestFindRunningContainer_RoutesThroughProvisionerSSOT(t *testing.T) {
if !callsRunningContainerName {
t.Errorf(
"findRunningContainer must call provisioner.RunningContainerNameFunc for the SSOT inspect — see molecule-core#10. Found no such call.",
"findRunningContainer must call provisioner.RunningContainerName for the SSOT inspect — see molecule-core#10. Found no such call.",
)
}
if callsContainerInspectRaw {
@@ -1,346 +0,0 @@
package handlers
// plugins_listing_test.go — coverage for plugins_listing.go.
//
// Covered handlers:
// - ListRegistry GET /plugins
// - ListAvailableForWorkspace GET /workspaces/:id/plugins/available
// - ListInstalled GET /workspaces/:id/plugins
// - CheckRuntimeCompatibility GET /workspaces/:id/plugins/compatibility?runtime=<name>
//
// The Docker client is NOT mocked directly. Instead, the package-level
// provisioner.RunningContainerNameFunc is swapped via provisioner.StubRunningContainerName.
// This mirrors the existing stubInstallPluginViaEIC pattern and avoids needing
// to implement the full docker.APIClient interface.
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
"github.com/docker/docker/client"
"github.com/gin-gonic/gin"
)
// ─── helpers ──────────────────────────────────────────────────────────────────
// stageRegistry creates a plugin dir under tmpDir with a plugin.yaml.
func stageRegistry(t *testing.T, tmpDir, name, manifestYAML string) {
t.Helper()
plugDir := filepath.Join(tmpDir, name)
if err := os.Mkdir(plugDir, 0755); err != nil {
t.Fatalf("mkdir plugin dir: %v", err)
}
if err := os.WriteFile(filepath.Join(plugDir, "plugin.yaml"), []byte(manifestYAML), 0644); err != nil {
t.Fatalf("write plugin.yaml: %v", err)
}
}
// stubContainerRunning stubs RunningContainerNameFunc so findRunningContainer
// returns the given container name (empty string = container not running).
func stubContainerRunning(t *testing.T, name string) {
t.Helper()
provisioner.StubRunningContainerName(t,
func(ctx context.Context, cli *client.Client, workspaceID string) (string, error) {
return name, nil
},
)
}
// ─── ListRegistry ──────────────────────────────────────────────────────────────
func TestListRegistry_EmptyDir_ReturnsEmptyArray(t *testing.T) {
tmpDir := t.TempDir()
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 0 {
t.Fatalf("expected empty array, got %d plugins", len(plugins))
}
}
func TestListRegistry_WithPlugins_ReturnsPluginInfo(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "browser-automation",
"name: browser-automation\nversion: \"1.2.0\"\ndescription: Browser automation\nauthor: molecule\ntags:\n - browser\n - playwright\nskills:\n - automates-ui\nruntimes:\n - claude_code\n")
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if plugins[0]["name"] != "browser-automation" {
t.Errorf("unexpected plugin: %v", plugins[0])
}
if plugins[0]["version"] != "1.2.0" {
t.Errorf("expected version 1.2.0, got %v", plugins[0]["version"])
}
}
func TestListRegistry_RuntimeFilter_IncludesUnspecifiedPlugin(t *testing.T) {
tmpDir := t.TempDir()
// Plugin without runtimes field — treated as "unspecified, try it".
stageRegistry(t, tmpDir, "generic-tool",
"name: generic-tool\nversion: \"1.0.0\"\ndescription: Works everywhere\n")
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 || plugins[0]["name"] != "generic-tool" {
t.Errorf("expected [generic-tool], got %v", plugins)
}
}
func TestListRegistry_RuntimeFilter_ExcludesIncompatiblePlugin(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "hermes-only",
"name: hermes-only\nversion: \"1.0.0\"\nruntimes:\n - hermes\n")
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 0 {
t.Errorf("expected 0 plugins (hermes-only filtered out), got %d", len(plugins))
}
}
func TestListRegistry_RuntimeFilter_NormalizesHyphen(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "tool-x",
"name: tool-x\nversion: \"1.0.0\"\nruntimes:\n - claude-code\n")
h := NewPluginsHandler(tmpDir, nil, nil)
// Query uses "claude_code" (underscore); manifest uses "claude-code" (hyphen).
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (hyphen/underscore normalised), got %d", len(plugins))
}
}
func TestListRegistry_MultipleRuntimes_PluginIncludedForEach(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "multi-tool",
"name: multi-tool\nversion: \"1.0.0\"\nruntimes:\n - claude_code\n - hermes\n")
h := NewPluginsHandler(tmpDir, nil, nil)
for _, runtime := range []string{"claude_code", "hermes"} {
t.Run(runtime, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime="+runtime, nil)
h.ListRegistry(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin for runtime %s, got %d", runtime, len(plugins))
}
})
}
}
// ─── ListAvailableForWorkspace ────────────────────────────────────────────────
func TestListAvailableForWorkspace_NoRuntimeLookup_ReturnsUnfiltered(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "tool-a", "name: tool-a\nruntimes:\n - claude_code\n")
stageRegistry(t, tmpDir, "tool-b", "name: tool-b\nruntimes:\n - hermes\n")
h := NewPluginsHandler(tmpDir, nil, nil)
// No runtimeLookup → unfiltered registry
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-any"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-any/plugins/available", nil)
h.ListAvailableForWorkspace(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 2 {
t.Errorf("expected 2 plugins (unfiltered), got %d", len(plugins))
}
}
func TestListAvailableForWorkspace_WithRuntimeLookup_FiltersByRuntime(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "tool-claude", "name: tool-claude\nruntimes:\n - claude_code\n")
stageRegistry(t, tmpDir, "tool-hermes", "name: tool-hermes\nruntimes:\n - hermes\n")
h := NewPluginsHandler(tmpDir, nil, nil).WithRuntimeLookup(
func(workspaceID string) (string, error) { return "claude_code", nil },
)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-claude"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-claude/plugins/available", nil)
h.ListAvailableForWorkspace(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 1 || plugins[0]["name"] != "tool-claude" {
t.Errorf("expected [tool-claude], got %v", plugins)
}
}
func TestListAvailableForWorkspace_RuntimeLookupError_FallsBackToUnfiltered(t *testing.T) {
tmpDir := t.TempDir()
stageRegistry(t, tmpDir, "tool-any", "name: tool-any\nruntimes:\n - claude_code\n")
h := NewPluginsHandler(tmpDir, nil, nil).WithRuntimeLookup(
func(workspaceID string) (string, error) { return "", fmt.Errorf("db unavailable") },
)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-err"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-err/plugins/available", nil)
h.ListAvailableForWorkspace(c)
var plugins []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// Falls back to unfiltered (empty string runtime = include all)
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (unfiltered fallback), got %d", len(plugins))
}
}
// ─── ListInstalled ──────────────────────────────────────────────────────────────
func TestListInstalled_ContainerNotRunning_ReturnsEmpty(t *testing.T) {
tmpDir := t.TempDir()
// Stub RunningContainerName to return "" (container not running).
stubContainerRunning(t, "")
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-stopped"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-stopped/plugins", nil)
h.ListInstalled(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []any
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(plugins) != 0 {
t.Errorf("expected empty array (container not running), got %d plugins", len(plugins))
}
}
// ─── CheckRuntimeCompatibility ────────────────────────────────────────────────
func TestCheckRuntimeCompatibility_MissingRuntimeParam_Returns400(t *testing.T) {
tmpDir := t.TempDir()
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-x"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-x/plugins/compatibility", nil)
h.CheckRuntimeCompatibility(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
if !bytes.Contains(w.Body.Bytes(), []byte("runtime")) {
t.Errorf("expected 'runtime' in error body, got: %s", w.Body.String())
}
}
func TestCheckRuntimeCompatibility_ContainerNotRunning_ReturnsAllCompatible(t *testing.T) {
tmpDir := t.TempDir()
stubContainerRunning(t, "") // container not running
h := NewPluginsHandler(tmpDir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "ws-stopped"}}
c.Request = httptest.NewRequest("GET", "/workspaces/ws-stopped/plugins/compatibility?runtime=claude_code", nil)
h.CheckRuntimeCompatibility(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["all_compatible"] != true {
t.Errorf("expected all_compatible=true (no container), got %v", resp["all_compatible"])
}
}
@@ -1,35 +1,21 @@
package handlers
// plugins_sources_test.go — coverage for plugins_sources.go (ListSources).
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/plugins"
"github.com/gin-gonic/gin"
)
// stubPluginSources implements pluginSources for test purposes.
type stubPluginSources struct {
schemes []string
}
func (s *stubPluginSources) Register(resolver plugins.SourceResolver) {}
func (s *stubPluginSources) Resolve(source plugins.Source) (plugins.SourceResolver, error) {
return nil, nil
}
func (s *stubPluginSources) Schemes() []string { return s.schemes }
// TestListSources_ReturnsSchemes verifies the endpoint returns whatever the
// source registry reports — the handler itself is a thin passthrough.
// ListSources is the only exported function in plugins_sources.go.
// It calls h.sources.Schemes() and returns the result verbatim,
// so the test verifies the handler correctly serialises whatever
// the real registry provides.
func TestListSources_ReturnsSchemes(t *testing.T) {
// Build a PluginsHandler with a stub source registry.
h := &PluginsHandler{sources: &stubPluginSources{
schemes: []string{"local://", "github://", "clawhub://"},
}}
// Use a real handler — the registry is deterministic (local + github).
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -38,39 +24,32 @@ func TestListSources_ReturnsSchemes(t *testing.T) {
h.ListSources(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
var body struct {
Schemes []string `json:"schemes"`
}
schemes, ok := resp["schemes"].([]any)
if !ok {
t.Fatalf("expected 'schemes' array, got %T", resp["schemes"])
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(schemes) != 3 {
t.Errorf("expected 3 schemes, got %d: %v", len(schemes), schemes)
}
}
func TestListSources_EmptyRegistry_ReturnsEmptyArray(t *testing.T) {
h := &PluginsHandler{sources: &stubPluginSources{schemes: []string{}}}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
schemes := resp["schemes"].([]any)
if len(schemes) != 0 {
t.Errorf("expected empty schemes, got %v", schemes)
// The default registry registers local + github resolvers.
if len(body.Schemes) < 1 {
t.Fatalf("expected at least 1 scheme, got %d: %v", len(body.Schemes), body.Schemes)
}
// Verify stability — same call always returns same result.
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
c2.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c2)
var body2 struct {
Schemes []string `json:"schemes"`
}
json.Unmarshal(w2.Body.Bytes(), &body2)
if len(body.Schemes) != len(body2.Schemes) {
t.Errorf("Schemes() is not stable: first=%v, second=%v", body.Schemes, body2.Schemes)
}
}
@@ -0,0 +1,297 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
func setupAbilitiesTest(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prev := db.DB
db.DB = mockDB
return mock, func() {
db.DB = prev
mockDB.Close()
}
}
func TestPatchAbilities_InvalidWorkspaceID_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "not-a-valid-uuid"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/not-a-valid-uuid/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "invalid workspace ID" {
t.Errorf("expected 'invalid workspace ID', got %q", body["error"])
}
}
func TestPatchAbilities_EmptyBody_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "at least one ability field required" {
t.Errorf("expected 'at least one ability field required', got %q", body["error"])
}
}
func TestPatchAbilities_InvalidJSON_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{invalid json}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "invalid request body" {
t.Errorf("expected 'invalid request body', got %q", body["error"])
}
}
func TestPatchAbilities_WorkspaceNotFound_Returns404(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(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 expectations: %v", err)
}
}
func TestPatchAbilities_WorkspaceDBError_Returns404(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(errors.New("connection refused"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(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 expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBroadcastEnabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_UpdateTalkToUserEnabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"talk_to_user_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBothAbilities_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", false).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true,"talk_to_user_enabled":false}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBroadcastDisabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", false).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":false}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
@@ -0,0 +1,398 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
// -------------------------------------------------------------------------- //
// broadcastTruncate
// -------------------------------------------------------------------------- //
func TestBroadcastTruncate_ShortString_ReturnsUnmodified(t *testing.T) {
result := broadcastTruncate("hello", 10)
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}
func TestBroadcastTruncate_ExactlyMaxLength_ReturnsUnmodified(t *testing.T) {
result := broadcastTruncate("hello", 5)
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}
func TestBroadcastTruncate_ExceedsMaxLength_TruncatesWithEllipsis(t *testing.T) {
result := broadcastTruncate("hello world", 5)
if result != "hello…" {
t.Errorf("expected 'hello…', got %q", result)
}
}
func TestBroadcastTruncate_Unicode_TruncatesAtRuneBoundary(t *testing.T) {
result := broadcastTruncate("日本語テスト", 2)
if result != "日本…" {
t.Errorf("expected '日本…', got %q", result)
}
}
// -------------------------------------------------------------------------- //
// BroadcastHandler
// -------------------------------------------------------------------------- //
func setupBroadcastTest(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prev := db.DB
db.DB = mockDB
return mock, func() {
db.DB = prev
mockDB.Close()
}
}
func TestBroadcast_InvalidWorkspaceID_Returns400(t *testing.T) {
_, cleanup := setupBroadcastTest(t)
defer cleanup()
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
c.Request = httptest.NewRequest("POST", "/workspaces/not-a-uuid/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "invalid workspace ID" {
t.Errorf("expected 'invalid workspace ID', got %q", body["error"])
}
}
func TestBroadcast_MissingMessage_Returns400(t *testing.T) {
_, cleanup := setupBroadcastTest(t)
defer cleanup()
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "message is required" {
t.Errorf("expected 'message is required', got %q", body["error"])
}
}
func TestBroadcast_WorkspaceNotFound_Returns404(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(sql.ErrNoRows)
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(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 expectations: %v", err)
}
}
func TestBroadcast_BroadcastDisabled_Returns403(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-agent", false))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "broadcast_disabled" {
t.Errorf("expected error='broadcast_disabled', got %v", body)
}
if _, ok := body["hint"]; !ok {
t.Errorf("expected hint field in 403 body, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_RecipientQueryFails_Returns500(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-agent", true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(errors.New("connection refused"))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(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 expectations: %v", err)
}
}
func TestBroadcast_NoRecipients_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-agent", true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs("550e8400-e29b-41d4-a716-446655440000", "Broadcast sent to 0 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "sent" {
t.Errorf("expected status=sent, got %v", body)
}
if int(body["delivered"].(float64)) != 0 {
t.Errorf("expected delivered=0, got %v", body["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_DeliversToOneRecipient_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
senderID := "550e8400-e29b-41d4-a716-446655440000"
recipientID := "660e8400-e29b-41d4-a716-446655440001"
senderName := "test-agent"
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow(senderName, true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(senderID, "Broadcast sent to 1 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
c.Request = httptest.NewRequest("POST",
"/workspaces/"+senderID+"/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if int(body["delivered"].(float64)) != 1 {
t.Errorf("expected delivered=1, got %v", body["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_RecipientInsertFails_Continues_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
senderID := "550e8400-e29b-41d4-a716-446655440000"
recipientID := "660e8400-e29b-41d4-a716-446655440001"
senderName := "test-agent"
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow(senderName, true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
WillReturnError(errors.New("connection refused"))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(senderID, "Broadcast sent to 0 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
c.Request = httptest.NewRequest("POST",
"/workspaces/"+senderID+"/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if int(body["delivered"].(float64)) != 0 {
t.Errorf("expected delivered=0 (failed inserts don't count), got %v", body["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_SenderLogFails_StillReturns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
senderID := "550e8400-e29b-41d4-a716-446655440000"
recipientID := "660e8400-e29b-41d4-a716-446655440001"
senderName := "test-agent"
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow(senderName, true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(senderID, "Broadcast sent to 1 workspace(s)").
WillReturnError(errors.New("connection refused"))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
c.Request = httptest.NewRequest("POST",
"/workspaces/"+senderID+"/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if int(body["delivered"].(float64)) != 1 {
t.Errorf("expected delivered=1, got %v", body["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
@@ -1,32 +0,0 @@
package provisioner
// provisioner_stub.go — test stub for RunningContainerName.
//
// RunningContainerNameFunc is a package-level variable pointing to the
// real RunningContainerName. Tests in other packages (handlers/) swap it
// via StubRunningContainerName so ListInstalled and CheckRuntimeCompatibility
// can be tested without a real Docker client.
//
// This file intentionally does NOT import "testing" — it ships in the
// production binary so the handlers package can call it.
import (
"context"
"testing"
"github.com/docker/docker/client"
)
// RunningContainerNameFunc is the pluggable entry point used by
// PluginsHandler.findRunningContainer. Defaults to RunningContainerName;
// swapped via StubRunningContainerName in tests.
var RunningContainerNameFunc = RunningContainerName
// StubRunningContainerName swaps RunningContainerNameFunc for the duration
// of a test; restored by t.Cleanup.
func StubRunningContainerName(t *testing.T, fn func(context.Context, *client.Client, string) (string, error)) {
t.Helper()
prev := RunningContainerNameFunc
RunningContainerNameFunc = fn
t.Cleanup(func() { RunningContainerNameFunc = prev })
}