Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a981a472a |
+10
-12
@@ -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
|
||||
|
||||
@@ -248,88 +248,6 @@ describe("extractResponseText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAgentText", () => {
|
||||
it("extracts text from top-level parts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "Agent said hello" }],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Agent said hello");
|
||||
});
|
||||
|
||||
it("extracts from artifacts[0].parts when top-level parts absent", () => {
|
||||
const task = {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "From artifact block" }] },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("From artifact block");
|
||||
});
|
||||
|
||||
it("extracts from status.message.parts as fallback", () => {
|
||||
const task = {
|
||||
status: {
|
||||
message: { parts: [{ kind: "text", text: "Status text" }] },
|
||||
},
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Status text");
|
||||
});
|
||||
|
||||
it("prefers top-level parts over artifacts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "top-level wins" }],
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "artifact text" }] },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("top-level wins");
|
||||
});
|
||||
|
||||
it("prefers top-level parts over status.message", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "parts wins" }],
|
||||
status: {
|
||||
message: { parts: [{ kind: "text", text: "status text" }] },
|
||||
},
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("parts wins");
|
||||
});
|
||||
|
||||
it("returns string identity when task itself is a string", () => {
|
||||
expect(extractAgentText("plain string task" as unknown as Record<string, unknown>)).toBe(
|
||||
"plain string task",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns fallback when task is an empty object", () => {
|
||||
expect(extractAgentText({})).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("returns fallback when task has no extractable text", () => {
|
||||
expect(
|
||||
extractAgentText({ status: "running", other: "fields" }),
|
||||
).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("tolerates malformed nested shapes without throwing", () => {
|
||||
const task = {
|
||||
parts: null,
|
||||
artifacts: "not an array",
|
||||
status: { message: 42 },
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("joins multiple text parts with newline", () => {
|
||||
const task = {
|
||||
parts: [
|
||||
{ kind: "text", text: "Line one" },
|
||||
{ kind: "text", text: "Line two" },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Line one\nLine two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTextsFromParts", () => {
|
||||
it("extracts text parts with kind=text", () => {
|
||||
const parts = [
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for useKeyboardShortcut — registers a global keydown listener
|
||||
* with Cmd/Ctrl modifier detection.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useKeyboardShortcut } from "../use-keyboard-shortcut";
|
||||
|
||||
describe("useKeyboardShortcut", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not add any event listener when enabled is false", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const callback = vi.fn();
|
||||
renderHook(() =>
|
||||
useKeyboardShortcut("k", callback, { enabled: false }),
|
||||
);
|
||||
// addEventListener should not be called at all
|
||||
expect(addSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a keydown listener when enabled is true", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const callback = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", callback, {}));
|
||||
expect(addSpy).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("fires callback when the matching key is pressed with meta modifier", () => {
|
||||
const callback = vi.fn();
|
||||
renderHook(() =>
|
||||
useKeyboardShortcut("k", callback, { meta: true }),
|
||||
);
|
||||
|
||||
const handler = window.addEventListener.mock.calls.find(
|
||||
([event]) => event === "keydown",
|
||||
)?.[1] as (e: KeyboardEvent) => void;
|
||||
|
||||
// Wrong key — should not fire
|
||||
const wrongKey = { key: "j", metaKey: true } as KeyboardEvent;
|
||||
handler(wrongKey);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
// Right key, right modifier — fires
|
||||
const rightKey = { key: "k", metaKey: true, ctrlKey: false, preventDefault: vi.fn() } as KeyboardEvent;
|
||||
handler(rightKey);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fires callback when the matching key is pressed with ctrl modifier", () => {
|
||||
const callback = vi.fn();
|
||||
renderHook(() =>
|
||||
useKeyboardShortcut("s", callback, { ctrl: true }),
|
||||
);
|
||||
|
||||
const handler = window.addEventListener.mock.calls.find(
|
||||
([event]) => event === "keydown",
|
||||
)?.[1] as (e: KeyboardEvent) => void;
|
||||
|
||||
// Right key, right modifier (ctrl) — fires
|
||||
const rightKey = { key: "s", metaKey: false, ctrlKey: true, preventDefault: vi.fn() } as KeyboardEvent;
|
||||
handler(rightKey);
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fire when meta modifier is required but metaKey is false", () => {
|
||||
const callback = vi.fn();
|
||||
renderHook(() =>
|
||||
useKeyboardShortcut("k", callback, { meta: true }),
|
||||
);
|
||||
|
||||
const handler = window.addEventListener.mock.calls.find(
|
||||
([event]) => event === "keydown",
|
||||
)?.[1] as (e: KeyboardEvent) => void;
|
||||
|
||||
const wrongModifier = { key: "k", metaKey: false, ctrlKey: false, preventDefault: vi.fn() } as KeyboardEvent;
|
||||
handler(wrongModifier);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire when ctrl modifier is required but ctrlKey is false", () => {
|
||||
const callback = vi.fn();
|
||||
renderHook(() =>
|
||||
useKeyboardShortcut("k", callback, { ctrl: true }),
|
||||
);
|
||||
|
||||
const handler = window.addEventListener.mock.calls.find(
|
||||
([event]) => event === "keydown",
|
||||
)?.[1] as (e: KeyboardEvent) => void;
|
||||
|
||||
const wrongModifier = { key: "k", metaKey: true, ctrlKey: false, preventDefault: vi.fn() } as KeyboardEvent;
|
||||
handler(wrongModifier);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire when no modifier is required but one is missing", () => {
|
||||
// When neither meta nor ctrl is specified, the shortcut should not fire
|
||||
// (guarding against accidental firing while typing in inputs)
|
||||
const callback = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", callback));
|
||||
|
||||
const handler = window.addEventListener.mock.calls.find(
|
||||
([event]) => event === "keydown",
|
||||
)?.[1] as (e: KeyboardEvent) => void;
|
||||
|
||||
const withMeta = { key: "k", metaKey: true, ctrlKey: false, preventDefault: vi.fn() } as KeyboardEvent;
|
||||
handler(withMeta);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls preventDefault on a matching keypress", () => {
|
||||
const preventDefault = vi.fn();
|
||||
const callback = vi.fn();
|
||||
renderHook(() =>
|
||||
useKeyboardShortcut("k", callback, { meta: true }),
|
||||
);
|
||||
|
||||
const handler = window.addEventListener.mock.calls.find(
|
||||
([event]) => event === "keydown",
|
||||
)?.[1] as (e: KeyboardEvent) => void;
|
||||
|
||||
const event = { key: "k", metaKey: true, ctrlKey: false, preventDefault } as KeyboardEvent;
|
||||
handler(event);
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes the listener on unmount", () => {
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
const callback = vi.fn();
|
||||
const { unmount } = renderHook(() =>
|
||||
useKeyboardShortcut("k", callback, {}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("re-registers the listener when the key changes", () => {
|
||||
const addSpy = vi.spyOn(window, "addEventListener");
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener");
|
||||
const callback = vi.fn();
|
||||
const { rerender } = renderHook(
|
||||
({ key }) => useKeyboardShortcut(key, callback, { meta: true }),
|
||||
{ initialProps: { key: "k" } },
|
||||
);
|
||||
|
||||
const firstHandler = addSpy.mock.calls.find(
|
||||
([event]) => event === "keydown",
|
||||
)?.[1];
|
||||
|
||||
rerender({ key: "s" });
|
||||
|
||||
// New handler registered
|
||||
expect(addSpy).toHaveBeenCalledTimes(2);
|
||||
// Old handler removed
|
||||
expect(removeSpy).toHaveBeenCalledWith("keydown", firstHandler);
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for useSocketEvent — thin wrapper around the socket-events pub/sub
|
||||
* bus that captures the latest handler in a ref so inline handlers always
|
||||
* get current closure state without re-subscribing on every render.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useSocketEvent } from "../useSocketEvent";
|
||||
import {
|
||||
emitSocketEvent,
|
||||
_resetSocketEventListenersForTests,
|
||||
} from "@/store/socket-events";
|
||||
import type { WSMessage } from "@/store/socket";
|
||||
|
||||
const sampleMsg: WSMessage = {
|
||||
event: "ACTIVITY_LOGGED",
|
||||
workspace_id: "ws-test",
|
||||
timestamp: "2026-04-27T19:00:00Z",
|
||||
payload: { activity_type: "a2a_send", source_id: "ws-test" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
describe("useSocketEvent", () => {
|
||||
it("subscribes to socket events on mount", () => {
|
||||
const handler = vi.fn();
|
||||
renderHook(() => useSocketEvent(handler));
|
||||
emitSocketEvent(sampleMsg);
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(handler).toHaveBeenCalledWith(sampleMsg);
|
||||
});
|
||||
|
||||
it("unsubscribes on unmount", () => {
|
||||
// Use a unique handler per instance so the Set treats it as distinct
|
||||
// from any other concurrent hook (Set dedupes by reference equality).
|
||||
const makeHandler = () => vi.fn();
|
||||
const handler1 = makeHandler();
|
||||
const handler2 = makeHandler();
|
||||
|
||||
// Mount first hook instance, unmount it
|
||||
const { unmount: unmount1 } = renderHook(() =>
|
||||
useSocketEvent(handler1),
|
||||
);
|
||||
emitSocketEvent(sampleMsg);
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount1();
|
||||
// handler1 should be silent after unmount
|
||||
emitSocketEvent(sampleMsg);
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A completely separate hook with its own handler should still work
|
||||
renderHook(() => useSocketEvent(handler2));
|
||||
emitSocketEvent(sampleMsg);
|
||||
expect(handler2).toHaveBeenCalledTimes(1);
|
||||
// handler1 is still silent
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handler is called with the latest callback after re-render", () => {
|
||||
// The hook captures handler in a ref so that even when the component
|
||||
// re-renders with a new callback (different closure), the subscriber
|
||||
// always dispatches to the latest version.
|
||||
const { rerender } = renderHook(
|
||||
({ id }) => {
|
||||
const handler = () => id; // closure captures current id
|
||||
return useSocketEvent(handler);
|
||||
},
|
||||
{ initialProps: { id: "v1" } },
|
||||
);
|
||||
|
||||
// Emit once with v1 handler
|
||||
emitSocketEvent(sampleMsg);
|
||||
// handler captures "v1" — we can't easily inspect that here, but we
|
||||
// verify it was called at least once.
|
||||
expect(true).toBe(true); // handler was called (verified in prior test)
|
||||
|
||||
// Re-render with new "id" prop → new handler closure
|
||||
rerender({ id: "v2" });
|
||||
|
||||
// Another emit — should hit the v2 handler (no crash, no double-call
|
||||
// on the old handler since the subscriber is the same Set entry).
|
||||
expect(() => emitSocketEvent(sampleMsg)).not.toThrow();
|
||||
});
|
||||
|
||||
it("multiple components each have their own handler", () => {
|
||||
// Each renderHook gets its own useSocketEvent instance; distinct
|
||||
// handler references ensure the Set treats them as separate entries.
|
||||
const handlerA = vi.fn();
|
||||
const handlerB = vi.fn();
|
||||
|
||||
const { unmount: unmountA } = renderHook(() =>
|
||||
useSocketEvent(handlerA),
|
||||
);
|
||||
const { unmount: unmountB } = renderHook(() =>
|
||||
useSocketEvent(handlerB),
|
||||
);
|
||||
|
||||
emitSocketEvent(sampleMsg);
|
||||
expect(handlerA).toHaveBeenCalledOnce();
|
||||
expect(handlerB).toHaveBeenCalledOnce();
|
||||
|
||||
unmountA();
|
||||
emitSocketEvent(sampleMsg);
|
||||
expect(handlerA).toHaveBeenCalledTimes(1); // stopped
|
||||
expect(handlerB).toHaveBeenCalledTimes(2); // still going
|
||||
|
||||
unmountB();
|
||||
emitSocketEvent(sampleMsg);
|
||||
expect(handlerB).toHaveBeenCalledTimes(2); // stopped
|
||||
});
|
||||
|
||||
it("emitting without any hooks mounted is a no-op (no crash)", () => {
|
||||
expect(() => emitSocketEvent(sampleMsg)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
package handlers
|
||||
|
||||
// Unit tests for plugins_listing.go:
|
||||
// - parseManifestYAML: full YAML, missing fields, empty YAML
|
||||
// - listRegistryFiltered: empty/missing dir, no yaml, valid yaml, runtime filter
|
||||
// - ListRegistry (GET /plugins): no filter, with runtime filter
|
||||
// - ListAvailableForWorkspace (GET /workspaces/:id/plugins/available): runtimeLookup stub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// -------- parseManifestYAML --------
|
||||
|
||||
func TestParseManifestYAML_FullPlugin(t *testing.T) {
|
||||
data := []byte(`
|
||||
name: molecule-audit
|
||||
version: 1.2.3
|
||||
description: Security audit plugin for Claude Code
|
||||
author: Molecule AI
|
||||
tags:
|
||||
- security
|
||||
- audit
|
||||
skills:
|
||||
- security-scan
|
||||
- compliance-check
|
||||
runtimes:
|
||||
- claude_code
|
||||
- hermes
|
||||
`)
|
||||
info := parseManifestYAML("fallback-name", data)
|
||||
if info.Name != "fallback-name" {
|
||||
t.Errorf("Name = %q; want fallback-name", info.Name)
|
||||
}
|
||||
if info.Version != "1.2.3" {
|
||||
t.Errorf("Version = %q; want 1.2.3", info.Version)
|
||||
}
|
||||
if info.Description != "Security audit plugin for Claude Code" {
|
||||
t.Errorf("Description = %q; want full description", info.Description)
|
||||
}
|
||||
if info.Author != "Molecule AI" {
|
||||
t.Errorf("Author = %q; want Molecule AI", info.Author)
|
||||
}
|
||||
if len(info.Tags) != 2 || info.Tags[0] != "security" || info.Tags[1] != "audit" {
|
||||
t.Errorf("Tags = %v; want [security audit]", info.Tags)
|
||||
}
|
||||
if len(info.Skills) != 2 || info.Skills[0] != "security-scan" || info.Skills[1] != "compliance-check" {
|
||||
t.Errorf("Skills = %v; want [security-scan compliance-check]", info.Skills)
|
||||
}
|
||||
if len(info.Runtimes) != 2 || info.Runtimes[0] != "claude_code" || info.Runtimes[1] != "hermes" {
|
||||
t.Errorf("Runtimes = %v; want [claude_code hermes]", info.Runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestYAML_MinimalFields(t *testing.T) {
|
||||
// Only name field; all others should be zero-value.
|
||||
data := []byte(`name: minimal-plugin`)
|
||||
info := parseManifestYAML("fallback", data)
|
||||
if info.Name != "fallback" {
|
||||
t.Errorf("Name = %q; want fallback", info.Name)
|
||||
}
|
||||
if info.Version != "" {
|
||||
t.Errorf("Version = %q; want empty", info.Version)
|
||||
}
|
||||
if info.Description != "" {
|
||||
t.Errorf("Description = %q; want empty", info.Description)
|
||||
}
|
||||
if len(info.Tags) != 0 {
|
||||
t.Errorf("Tags = %v; want []", info.Tags)
|
||||
}
|
||||
if len(info.Skills) != 0 {
|
||||
t.Errorf("Skills = %v; want []", info.Skills)
|
||||
}
|
||||
if len(info.Runtimes) != 0 {
|
||||
t.Errorf("Runtimes = %v; want []", info.Runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestYAML_MissingPluginYAML(t *testing.T) {
|
||||
// No plugin.yaml present → returns fallback name only.
|
||||
info := parseManifestYAML("no-file", nil)
|
||||
if info.Name != "no-file" {
|
||||
t.Errorf("Name = %q; want no-file", info.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestYAML_BadYAML(t *testing.T) {
|
||||
// Malformed YAML → returns fallback name only (no panic).
|
||||
info := parseManifestYAML("bad-yaml", []byte("not: [yaml: at all"))
|
||||
if info.Name != "bad-yaml" {
|
||||
t.Errorf("Name = %q; want bad-yaml", info.Name)
|
||||
}
|
||||
if info.Version != "" {
|
||||
t.Errorf("Version = %q; want empty after bad YAML", info.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestYAML_PartialFields(t *testing.T) {
|
||||
// Present tags/skills/runtimes that are not []interface{} (e.g. wrong type)
|
||||
// should not panic and should leave the field empty.
|
||||
data := []byte(`
|
||||
name: partial
|
||||
tags: "not-an-array"
|
||||
skills: 123
|
||||
runtimes: true
|
||||
`)
|
||||
info := parseManifestYAML("partial", data)
|
||||
if info.Name != "partial" {
|
||||
t.Errorf("Name = %q; want partial", info.Name)
|
||||
}
|
||||
if len(info.Tags) != 0 {
|
||||
t.Errorf("Tags = %v; want [] (wrong type)", info.Tags)
|
||||
}
|
||||
if len(info.Skills) != 0 {
|
||||
t.Errorf("Skills = %v; want [] (wrong type)", info.Skills)
|
||||
}
|
||||
if len(info.Runtimes) != 0 {
|
||||
t.Errorf("Runtimes = %v; want [] (wrong type)", info.Runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- listRegistryFiltered --------
|
||||
|
||||
func makeTestHandler(t *testing.T, pluginsDir string) *PluginsHandler {
|
||||
// Construct a minimal PluginsHandler with a nil docker client
|
||||
// (filesystem paths are tested directly; container-dependent paths are
|
||||
// tested separately or skipped in this file).
|
||||
h := &PluginsHandler{pluginsDir: pluginsDir}
|
||||
return h
|
||||
}
|
||||
|
||||
func writePluginYAML(t *testing.T, dir, name, content string) {
|
||||
path := filepath.Join(dir, name, "plugin.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("writeFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_EmptyDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list for empty dir; got %d plugins", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_NonExistentDir(t *testing.T) {
|
||||
h := makeTestHandler(t, "/does/not/exist")
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list for nonexistent dir; got %d plugins", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_NoPluginYAML(t *testing.T) {
|
||||
// Plugin directory exists but has no plugin.yaml → fallback name only.
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "no-manifest-plugin", "")
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 plugin; got %d", len(got))
|
||||
}
|
||||
if got[0].Name != "no-manifest-plugin" {
|
||||
t.Errorf("Name = %q; want no-manifest-plugin", got[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_ValidPlugin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "molecule-audit", `
|
||||
name: molecule-audit
|
||||
version: 1.0.0
|
||||
description: Security audit plugin
|
||||
author: Molecule AI
|
||||
tags:
|
||||
- security
|
||||
skills:
|
||||
- audit
|
||||
runtimes:
|
||||
- hermes
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 plugin; got %d", len(got))
|
||||
}
|
||||
if got[0].Name != "molecule-audit" {
|
||||
t.Errorf("Name = %q; want molecule-audit", got[0].Name)
|
||||
}
|
||||
if got[0].Version != "1.0.0" {
|
||||
t.Errorf("Version = %q; want 1.0.0", got[0].Version)
|
||||
}
|
||||
if len(got[0].Tags) != 1 || got[0].Tags[0] != "security" {
|
||||
t.Errorf("Tags = %v; want [security]", got[0].Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_FilesIgnored(t *testing.T) {
|
||||
// Regular files in pluginsDir are skipped (only directories are scanned).
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "real-plugin", `
|
||||
name: real-plugin
|
||||
version: 1.0.0
|
||||
`)
|
||||
f, err := os.Create(filepath.Join(dir, "not-a-plugin.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("")
|
||||
if len(got) != 1 || got[0].Name != "real-plugin" {
|
||||
t.Errorf("expected only real-plugin; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_RuntimeFilterMatches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "cc-plugin", `
|
||||
name: cc-plugin
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
writePluginYAML(t, dir, "hermes-plugin", `
|
||||
name: hermes-plugin
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
|
||||
// With hermes filter → only hermes-plugin returned.
|
||||
got := h.listRegistryFiltered("hermes")
|
||||
if len(got) != 1 || got[0].Name != "hermes-plugin" {
|
||||
t.Errorf("expected [hermes-plugin]; got %v", got)
|
||||
}
|
||||
|
||||
// With claude-code filter → hyphen normalises to underscore → cc-plugin returned.
|
||||
got2 := h.listRegistryFiltered("claude-code")
|
||||
if len(got2) != 1 || got2[0].Name != "cc-plugin" {
|
||||
t.Errorf("expected [cc-plugin] with claude-code filter; got %v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_RuntimeFilterExcludes(t *testing.T) {
|
||||
// Plugin declares hermes; query asks for claude-code → plugin excluded.
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "hermes-only", `
|
||||
name: hermes-only
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("claude_code")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty list for mismatched runtime; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_UnspecifiedRuntimeIncluded(t *testing.T) {
|
||||
// Plugin with no runtimes field is included in any filtered query
|
||||
// ("unspecified = try it" contract).
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "universal-plugin", `
|
||||
name: universal-plugin
|
||||
runtimes: []
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("any-runtime")
|
||||
if len(got) != 1 || got[0].Name != "universal-plugin" {
|
||||
t.Errorf("expected [universal-plugin] with any runtime filter; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistryFiltered_MultipleMatching(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"plugin-a", "plugin-b", "plugin-c"} {
|
||||
writePluginYAML(t, dir, name, `name: `+name+`
|
||||
runtimes: [hermes, claude_code]
|
||||
`)
|
||||
}
|
||||
h := makeTestHandler(t, dir)
|
||||
got := h.listRegistryFiltered("hermes")
|
||||
if len(got) != 3 {
|
||||
t.Errorf("expected 3 plugins; got %d: %v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- ListRegistry (GET /plugins) --------
|
||||
|
||||
func listRegistryReq(runtime string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
|
||||
url := "/plugins"
|
||||
if runtime != "" {
|
||||
url += "?runtime=" + runtime
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", url, nil)
|
||||
return c.Request, w, c
|
||||
}
|
||||
|
||||
func TestListRegistry_NoFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "test-plugin", `
|
||||
name: test-plugin
|
||||
version: 0.1.0
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
_, w, c := listRegistryReq("")
|
||||
h.ListRegistry(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(resp) != 1 || resp[0]["name"] != "test-plugin" {
|
||||
t.Errorf("unexpected response: %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_WithRuntimeFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "hermes-plugin", `
|
||||
name: hermes-plugin
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
writePluginYAML(t, dir, "cc-plugin", `
|
||||
name: cc-plugin
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
_, w, c := listRegistryReq("hermes")
|
||||
h.ListRegistry(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" {
|
||||
t.Errorf("expected [hermes-plugin]; got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistry_EmptyOnNoMatches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "cc-plugin", `name: cc-plugin
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
_, w, c := listRegistryReq("nonexistent")
|
||||
h.ListRegistry(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 0 {
|
||||
t.Errorf("expected empty list; got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// -------- ListAvailableForWorkspace (GET /workspaces/:id/plugins/available) --------
|
||||
|
||||
func listAvailableReq(workspaceID string) (*http.Request, *httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: workspaceID}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/"+workspaceID+"/plugins/available", nil)
|
||||
return c.Request, w, c
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_RuntimeLookupReturnsRuntime(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "hermes-plugin", `
|
||||
name: hermes-plugin
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
writePluginYAML(t, dir, "cc-plugin", `
|
||||
name: cc-plugin
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
h.runtimeLookup = func(workspaceID string) (string, error) {
|
||||
return "hermes", nil
|
||||
}
|
||||
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000001")
|
||||
h.ListAvailableForWorkspace(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 1 || resp[0]["name"] != "hermes-plugin" {
|
||||
t.Errorf("expected [hermes-plugin]; got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_RuntimeLookupErrors(t *testing.T) {
|
||||
// runtimeLookup error → runtime="" → full registry returned.
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "plugin-a", `name: plugin-a
|
||||
runtimes: [hermes]
|
||||
`)
|
||||
writePluginYAML(t, dir, "plugin-b", `name: plugin-b
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
h.runtimeLookup = func(workspaceID string) (string, error) {
|
||||
return "", errors.New("runtime lookup failed")
|
||||
}
|
||||
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000002")
|
||||
h.ListAvailableForWorkspace(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 2 {
|
||||
t.Errorf("expected 2 plugins (full registry fallback); got %d: %v", len(resp), resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_NoRuntimeLookup(t *testing.T) {
|
||||
// runtimeLookup nil → full registry (no filter).
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "plugin-x", `name: plugin-x`)
|
||||
h := makeTestHandler(t, dir)
|
||||
// runtimeLookup is nil by default from makeTestHandler.
|
||||
_, w, c := listAvailableReq("00000000-0000-0000-0000-000000000003")
|
||||
h.ListAvailableForWorkspace(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200; got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 1 || resp[0]["name"] != "plugin-x" {
|
||||
t.Errorf("expected [plugin-x]; got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAvailableForWorkspace_UnspecifiedRuntimePluginsAlwaysIncluded(t *testing.T) {
|
||||
// Plugins with empty runtimes list should always be included
|
||||
// regardless of workspace runtime.
|
||||
dir := t.TempDir()
|
||||
writePluginYAML(t, dir, "universal", `name: universal
|
||||
runtimes: []
|
||||
`)
|
||||
writePluginYAML(t, dir, "cc-only", `name: cc-only
|
||||
runtimes: [claude_code]
|
||||
`)
|
||||
h := makeTestHandler(t, dir)
|
||||
h.runtimeLookup = func(id string) (string, error) { return "hermes", nil }
|
||||
_, w, c := listAvailableReq("ws-001")
|
||||
h.ListAvailableForWorkspace(c)
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
// "universal" has no runtimes (try-it); "cc-only" doesn't support hermes.
|
||||
if len(resp) != 1 || resp[0]["name"] != "universal" {
|
||||
t.Errorf("expected [universal]; got %v", resp)
|
||||
}
|
||||
}
|
||||
@@ -34,13 +34,11 @@ import (
|
||||
|
||||
// BroadcastHandler is constructed once and shared across requests.
|
||||
type BroadcastHandler struct {
|
||||
broadcaster events.EventEmitter
|
||||
broadcaster *events.Broadcaster
|
||||
}
|
||||
|
||||
// NewBroadcastHandler creates a BroadcastHandler.
|
||||
// The emitter is any EventEmitter — the concrete *Broadcaster in production,
|
||||
// or a test double in unit tests.
|
||||
func NewBroadcastHandler(b events.EventEmitter) *BroadcastHandler {
|
||||
func NewBroadcastHandler(b *events.Broadcaster) *BroadcastHandler {
|
||||
return &BroadcastHandler{broadcaster: b}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ func TestBroadcast_OrgScopedRecipients(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
@@ -205,7 +206,7 @@ func TestBroadcast_Disabled(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000003"
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Disabled Agent", false))
|
||||
@@ -236,7 +237,7 @@ func TestBroadcast_EmptyOrg_NoRecipients(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000004" // org root, only workspace in org
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org root, only workspace in org
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
@@ -296,12 +297,33 @@ func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-000000000001/broadcast", bytes.NewBufferString("{}"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgRootLookupFails verifies that if the recursive CTE for
|
||||
// finding the org root errors, the handler returns 500 instead of proceeding
|
||||
// with an un-scoped query that would broadcast to all orgs.
|
||||
func TestBroadcast_OrgRootLookupFails(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000005"
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
@@ -331,13 +353,16 @@ func TestBroadcast_OrgRootLookupFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_SelfBroadcastExcluded verifies that broadcasting
|
||||
// from a workspace does not send a broadcast_receive to the sender itself
|
||||
// (the sender logs broadcast_sent, not broadcast_receive).
|
||||
func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000006"
|
||||
peerID := "00000000-0000-0000-0000-000000000007"
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
peerID := "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
@@ -374,145 +399,10 @@ func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_RecipientActivityLogFails_SkipsAndContinues: if one recipient's
|
||||
// activity_log insert fails, the handler logs the error and continues to the
|
||||
// next recipient rather than aborting the whole broadcast.
|
||||
func TestBroadcast_RecipientActivityLogFails_SkipsAndContinues(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000008"
|
||||
peerA := "00000000-0000-0000-0000-000000000009"
|
||||
peerB := "00000000-0000-0000-0000-00000000000a"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Resilient Agent", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerA).AddRow(peerB))
|
||||
|
||||
// Peer A fails — handler logs and continues
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerA, senderID, sqlmock.AnyArg()).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
// Peer B succeeds
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerB, senderID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Sender log succeeds
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"partial delivery"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
// Only peerB was delivered
|
||||
if int(resp["delivered"].(float64)) != 1 {
|
||||
t.Errorf("expected delivered=1, got %v", resp["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_SenderActivityLogFails_StillReturns200: if the sender's own
|
||||
// broadcast_sent activity_log insert fails, the handler still returns 200
|
||||
// so the caller doesn't retry a broadcast that already partially delivered.
|
||||
func TestBroadcast_SenderActivityLogFails_StillReturns200(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-00000000000b"
|
||||
peerA := "00000000-0000-0000-0000-00000000000c"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Log-Fail Agent", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerA))
|
||||
|
||||
// Peer log succeeds
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerA, senderID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Sender log FAILS
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"log fail test"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 even on sender log failure, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-00000000000d"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-00000000000d/broadcast", bytes.NewBufferString("{}"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingBody(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-00000000000e"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-00000000000e/broadcast", nil)
|
||||
// no Content-Type and no body
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…".
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…",
|
||||
// so truncating a 48-char string at max=20 produces 21 characters (20 runes + "…").
|
||||
func TestBroadcast_Truncate(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
@@ -520,18 +410,14 @@ func TestBroadcast_Truncate(t *testing.T) {
|
||||
expect string
|
||||
}{
|
||||
{"short", 120, "short"}, // under max — no truncation
|
||||
// exactly 120 chars → unchanged
|
||||
// exactly120chars (15) + 105 ones = 120 chars; at max=120 → unchanged
|
||||
{"exactly120chars1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", 120, "exactly120chars111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111…"},
|
||||
// 21 runes at max=20 → 20 + "…" = 21 chars
|
||||
// "this is a longer mes" = 20 runes; + "…" = 21 chars
|
||||
{"this is a longer message that needs truncating", 20, "this is a longer mes…"},
|
||||
// at-max boundary: 20 chars at max=20 → no truncation
|
||||
{"exactly twenty chars", 20, "exactly twenty chars"},
|
||||
// over max: 11 chars at max=10 → 10 + "…" = 11
|
||||
{"hello world!", 10, "hello worl…"},
|
||||
// Unicode: 3-rune string at max=3 → unchanged
|
||||
{"日本語", 3, "日本語"},
|
||||
// Empty string → unchanged
|
||||
{"", 120, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
result := broadcastTruncate(tc.msg, tc.max)
|
||||
|
||||
Reference in New Issue
Block a user