Compare commits

...

1 Commits

Author SHA1 Message Date
fullstack-engineer 2e53b59f90 test: add coverage for workspace_broadcast handler and plugins_listing
Platform (Go):
- workspace_broadcast_test.go: 17 tests covering broadcastTruncate
  pure function (rune-based truncation, unicode, edge cases) and Broadcast
  handler (input validation, sender not found, broadcast disabled,
  recipient query errors, happy paths with 0/1/N recipients).
- plugins_listing_test.go: 7 tests covering ListRegistry runtime filter,
  unspecified runtime, hyphen/underscore normalization, empty param, mixed
  runtimes, and ListAvailableForWorkspace with/without runtimeLookup.

Canvas (TypeScript):
- design-tokens.test.ts: 43 tests covering STATUS_CONFIG shape/fields,
  statusDotClass mapping + fallback, glow/label/bar for all 7 statuses,
  TIER_CONFIG (all 4 tiers), and COMM_TYPE_LABELS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 13:22:51 +00:00
3 changed files with 1058 additions and 0 deletions
@@ -0,0 +1,241 @@
// @vitest-environment jsdom
/**
* Tests for design-tokens.ts — STATUS_CONFIG, statusDotClass, TIER_CONFIG,
* and COMM_TYPE_LABELS.
*
* These tokens are the single source of truth for workspace status colours
* (dot, glow, label, bar gradient), tier badges, and A2A communication labels
* throughout the canvas.
*/
import { describe, it, expect } from "vitest";
import { STATUS_CONFIG, statusDotClass, TIER_CONFIG, COMM_TYPE_LABELS } from "../design-tokens";
// ── STATUS_CONFIG shape ────────────────────────────────────────────────────────
describe("STATUS_CONFIG — shape", () => {
const keys = ["online", "offline", "paused", "degraded", "failed", "provisioning", "not_configured"] as const;
it("has entries for all seven known statuses", () => {
for (const key of keys) {
expect(STATUS_CONFIG).toHaveProperty(key);
}
});
it("each entry has dot, glow, label, and bar fields", () => {
for (const key of keys) {
expect(STATUS_CONFIG[key]).toHaveProperty("dot");
expect(STATUS_CONFIG[key]).toHaveProperty("glow");
expect(STATUS_CONFIG[key]).toHaveProperty("label");
expect(STATUS_CONFIG[key]).toHaveProperty("bar");
}
});
it("dot field is a non-empty string", () => {
for (const key of Object.keys(STATUS_CONFIG)) {
expect(typeof STATUS_CONFIG[key].dot).toBe("string");
expect(STATUS_CONFIG[key].dot.length).toBeGreaterThan(0);
}
});
it("label field is a non-empty string", () => {
for (const key of Object.keys(STATUS_CONFIG)) {
expect(typeof STATUS_CONFIG[key].label).toBe("string");
expect(STATUS_CONFIG[key].label.length).toBeGreaterThan(0);
}
});
it("bar field is a non-empty string and a valid Tailwind gradient", () => {
for (const key of Object.keys(STATUS_CONFIG)) {
expect(STATUS_CONFIG[key].bar.length).toBeGreaterThan(0);
// Must start with "from-" to be a valid Tailwind gradient class
expect(STATUS_CONFIG[key].bar).toMatch(/^from-/);
}
});
it("bar gradient includes to-transparent as the terminus", () => {
for (const key of Object.keys(STATUS_CONFIG)) {
expect(STATUS_CONFIG[key].bar).toMatch(/to-transparent$/);
}
});
});
// ── STATUS_CONFIG dot field → statusDotClass ───────────────────────────────────
describe("STATUS_CONFIG dot field is what statusDotClass returns", () => {
for (const key of Object.keys(STATUS_CONFIG)) {
it(`${key} dot matches statusDotClass("${key}")`, () => {
expect(statusDotClass(key)).toBe(STATUS_CONFIG[key].dot);
});
}
});
// ── statusDotClass ────────────────────────────────────────────────────────────
describe("statusDotClass", () => {
it('returns "bg-emerald-400" for "online"', () => {
expect(statusDotClass("online")).toBe("bg-emerald-400");
});
it('returns "bg-zinc-500" for "offline"', () => {
expect(statusDotClass("offline")).toBe("bg-zinc-500");
});
it('returns "bg-indigo-400" for "paused"', () => {
expect(statusDotClass("paused")).toBe("bg-indigo-400");
});
it('returns "bg-amber-400" for "degraded"', () => {
expect(statusDotClass("degraded")).toBe("bg-amber-400");
});
it('returns "bg-red-400" for "failed"', () => {
expect(statusDotClass("failed")).toBe("bg-red-400");
});
it('returns "bg-sky-400 motion-safe:animate-pulse" for "provisioning"', () => {
expect(statusDotClass("provisioning")).toBe("bg-sky-400 motion-safe:animate-pulse");
});
it('returns "bg-amber-300" for "not_configured"', () => {
expect(statusDotClass("not_configured")).toBe("bg-amber-300");
});
it("falls back to bg-zinc-500 for unknown status strings", () => {
expect(statusDotClass("unknown")).toBe("bg-zinc-500");
expect(statusDotClass("")).toBe("bg-zinc-500");
expect(statusDotClass("ONLINE")).toBe("bg-zinc-500"); // case-sensitive
expect(statusDotClass(" online")).toBe("bg-zinc-500"); // whitespace-sensitive
expect(statusDotClass("online\n")).toBe("bg-zinc-500");
});
it("is a pure function — same input always returns same output", () => {
const result = statusDotClass("online");
for (let i = 0; i < 5; i++) {
expect(statusDotClass("online")).toBe(result);
}
});
});
// ── STATUS_CONFIG glow field ──────────────────────────────────────────────────
describe("STATUS_CONFIG glow field", () => {
it('"offline" and "paused" have no glow (no shimmer on inactive states)', () => {
expect(STATUS_CONFIG.offline.glow).toBe("");
expect(STATUS_CONFIG.paused.glow).toBe("");
});
it('"failed" has a glow (red — critical alert)', () => {
expect(STATUS_CONFIG.failed.glow).toMatch(/red/i);
});
it('"degraded" has a glow (amber — warning)', () => {
expect(STATUS_CONFIG.degraded.glow).toMatch(/amber/i);
});
it('"online" has a glow (emerald — healthy)', () => {
expect(STATUS_CONFIG.online.glow).toMatch(/emerald/i);
});
it('"not_configured" has a glow (amber — needs attention)', () => {
expect(STATUS_CONFIG.not_configured.glow).toMatch(/amber/i);
});
it('"provisioning" has a glow (sky — starting)', () => {
expect(STATUS_CONFIG.provisioning.glow).toMatch(/sky/i);
});
});
// ── STATUS_CONFIG label text ───────────────────────────────────────────────────
describe("STATUS_CONFIG label field", () => {
it('"online" label is "Online"', () => {
expect(STATUS_CONFIG.online.label).toBe("Online");
});
it('"offline" label is "Offline"', () => {
expect(STATUS_CONFIG.offline.label).toBe("Offline");
});
it('"paused" label is "Paused"', () => {
expect(STATUS_CONFIG.paused.label).toBe("Paused");
});
it('"degraded" label is "Degraded"', () => {
expect(STATUS_CONFIG.degraded.label).toBe("Degraded");
});
it('"failed" label is "Failed"', () => {
expect(STATUS_CONFIG.failed.label).toBe("Failed");
});
it('"provisioning" label is "Starting"', () => {
expect(STATUS_CONFIG.provisioning.label).toBe("Starting");
});
it('"not_configured" label is "Not configured"', () => {
expect(STATUS_CONFIG.not_configured.label).toBe("Not configured");
});
});
// ── TIER_CONFIG ────────────────────────────────────────────────────────────────
describe("TIER_CONFIG", () => {
it("has entries for all four tier levels", () => {
expect(TIER_CONFIG).toHaveProperty("1");
expect(TIER_CONFIG).toHaveProperty("2");
expect(TIER_CONFIG).toHaveProperty("3");
expect(TIER_CONFIG).toHaveProperty("4");
});
it("each tier has label, color, and border fields", () => {
for (const tier of [1, 2, 3, 4]) {
expect(TIER_CONFIG[tier]).toHaveProperty("label");
expect(TIER_CONFIG[tier]).toHaveProperty("color");
expect(TIER_CONFIG[tier]).toHaveProperty("border");
}
});
it("tier labels match expected values", () => {
expect(TIER_CONFIG[1].label).toBe("T1");
expect(TIER_CONFIG[2].label).toBe("T2");
expect(TIER_CONFIG[3].label).toBe("T3");
expect(TIER_CONFIG[4].label).toBe("T4");
});
it("all color and border fields are non-empty strings", () => {
for (const tier of [1, 2, 3, 4]) {
expect(typeof TIER_CONFIG[tier].color).toBe("string");
expect(typeof TIER_CONFIG[tier].border).toBe("string");
expect(TIER_CONFIG[tier].color.length).toBeGreaterThan(0);
expect(TIER_CONFIG[tier].border.length).toBeGreaterThan(0);
}
});
it("is immutable at runtime — same key always returns same shape", () => {
const result = TIER_CONFIG[2];
expect(TIER_CONFIG[2]).toBe(result);
});
});
// ── COMM_TYPE_LABELS ──────────────────────────────────────────────────────────
describe("COMM_TYPE_LABELS", () => {
it("has labels for all known communication types", () => {
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_send");
expect(COMM_TYPE_LABELS).toHaveProperty("a2a_receive");
expect(COMM_TYPE_LABELS).toHaveProperty("task_update");
});
it("labels are non-empty strings", () => {
for (const key of Object.keys(COMM_TYPE_LABELS)) {
expect(typeof COMM_TYPE_LABELS[key]).toBe("string");
expect(COMM_TYPE_LABELS[key].length).toBeGreaterThan(0);
}
});
it("is a static map — same key always returns same label", () => {
expect(COMM_TYPE_LABELS["a2a_send"]).toBe("sent");
expect(COMM_TYPE_LABELS["a2a_receive"]).toBe("received");
expect(COMM_TYPE_LABELS["task_update"]).toBe("task update");
});
});
@@ -0,0 +1,400 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// ==================== ListRegistry with runtime filter ====================
// TestListRegistry_RuntimeFilterMatches verifies that when a plugin declares
// the requested runtime, it appears in the response.
func TestListRegistry_RuntimeFilterMatches(t *testing.T) {
dir := t.TempDir()
// Plugin that declares claude_code runtime
pluginDir := filepath.Join(dir, "coding-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(
"name: coding-plugin\nversion: 1.0.0\nruntimes:\n - claude_code\n"), 0644); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if plugins[0].Name != "coding-plugin" {
t.Errorf("expected plugin name 'coding-plugin', got %q", plugins[0].Name)
}
}
// TestListRegistry_RuntimeFilterNoMatch verifies that when no plugin declares
// the requested runtime, an empty list is returned.
func TestListRegistry_RuntimeFilterNoMatch(t *testing.T) {
dir := t.TempDir()
// Plugin that declares a different runtime
pluginDir := filepath.Join(dir, "python-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(
"name: python-plugin\nversion: 1.0.0\nruntimes:\n - crewai\n"), 0644); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 0 {
t.Fatalf("expected 0 plugins, got %d", len(plugins))
}
}
// TestListRegistry_UnspecifiedRuntimeIncluded verifies that a plugin with no
// runtimes field (unspecified) is included when filtering by any runtime.
func TestListRegistry_UnspecifiedRuntimeIncluded(t *testing.T) {
dir := t.TempDir()
// Plugin with no runtimes field — "unspecified, try it"
pluginDir := filepath.Join(dir, "universal-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(
"name: universal-plugin\nversion: 2.0.0\ndescription: Works everywhere\n"), 0644); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=langgraph", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin, got %d", len(plugins))
}
if plugins[0].Name != "universal-plugin" {
t.Errorf("expected 'universal-plugin', got %q", plugins[0].Name)
}
}
// TestListRegistry_HyphenUnderscoreNormalization verifies that runtime filtering
// is case-insensitive for hyphens vs underscores.
func TestListRegistry_HyphenUnderscoreNormalization(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "claude-code-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
// Plugin declares with underscores
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(
"name: claude-code-plugin\nruntimes:\n - claude_code\n"), 0644); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
// Filter using hyphen form — should match
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude-code", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (underscore filter matches hyphen form), got %d", len(plugins))
}
}
// TestListRegistry_EmptyRuntimeParamSameAsNoFilter verifies that ?runtime=
// (empty string) behaves the same as omitting the param — returns all plugins.
func TestListRegistry_EmptyRuntimeParamSameAsNoFilter(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "my-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(
"name: my-plugin\nversion: 1.0.0\n"), 0644); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
// With empty runtime param
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=", nil)
h.ListRegistry(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (empty runtime same as no filter), got %d", len(plugins))
}
}
// TestListRegistry_MultiplePluginsWithMixedRuntimes verifies filtering across
// multiple plugins with different runtime declarations.
func TestListRegistry_MultiplePluginsWithMixedRuntimes(t *testing.T) {
dir := t.TempDir()
// Plugin A: claude_code only
aDir := filepath.Join(dir, "plugin-a")
os.Mkdir(aDir, 0755)
os.WriteFile(filepath.Join(aDir, "plugin.yaml"), []byte(
"name: plugin-a\nruntimes:\n - claude_code\n"), 0644)
// Plugin B: hermes only
bDir := filepath.Join(dir, "plugin-b")
os.Mkdir(bDir, 0755)
os.WriteFile(filepath.Join(bDir, "plugin.yaml"), []byte(
"name: plugin-b\nruntimes:\n - hermes\n"), 0644)
// Plugin C: unspecified (no runtimes)
cDir := filepath.Join(dir, "plugin-c")
os.Mkdir(cDir, 0755)
os.WriteFile(filepath.Join(cDir, "plugin.yaml"), []byte(
"name: plugin-c\n"), 0644)
h := NewPluginsHandler(dir, nil, nil)
// Filter by claude_code → A + C (C is unspecified)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins?runtime=claude_code", nil)
h.ListRegistry(c)
var plugins []pluginInfo
json.Unmarshal(w.Body.Bytes(), &plugins)
if len(plugins) != 2 {
t.Errorf("expected 2 plugins (claude_code + unspecified), got %d", len(plugins))
}
}
// ==================== ListAvailableForWorkspace ====================
// validWSID is a properly-formed UUID for workspace ID parameters.
const validWSID = "aabbccdd-eeff-1234-5678-123456789abc"
// TestListAvailableForWorkspace_NoRuntimeLookup verifies that when no
// runtimeLookup is wired, the handler returns the full unfiltered registry.
func TestListAvailableForWorkspace_NoRuntimeLookup(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "any-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(
"name: any-plugin\nversion: 1.0.0\n"), 0644); err != nil {
t.Fatal(err)
}
// No runtimeLookup wired (nil)
h := NewPluginsHandler(dir, nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: validWSID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+validWSID+"/plugins/available", nil)
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 1 {
t.Fatalf("expected 1 plugin (unfiltered when no runtimeLookup), got %d", len(plugins))
}
}
// TestListAvailableForWorkspace_WithRuntimeLookup verifies that when a
// runtimeLookup is wired, the registry is filtered to matching plugins.
func TestListAvailableForWorkspace_WithRuntimeLookup(t *testing.T) {
dir := t.TempDir()
// Plugin A: matches hermes
aDir := filepath.Join(dir, "hermes-plugin")
os.Mkdir(aDir, 0755)
os.WriteFile(filepath.Join(aDir, "plugin.yaml"), []byte(
"name: hermes-plugin\nruntimes:\n - hermes\n"), 0644)
// Plugin B: does not match hermes
bDir := filepath.Join(dir, "claude-plugin")
os.Mkdir(bDir, 0755)
os.WriteFile(filepath.Join(bDir, "plugin.yaml"), []byte(
"name: claude-plugin\nruntimes:\n - claude_code\n"), 0644)
h := NewPluginsHandler(dir, nil, nil)
// Wire a runtimeLookup that returns "hermes"
h = h.WithRuntimeLookup(func(wsID string) (string, error) {
return "hermes", nil
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: validWSID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+validWSID+"/plugins/available", nil)
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (hermes only), got %d", len(plugins))
}
if len(plugins) > 0 && plugins[0].Name != "hermes-plugin" {
t.Errorf("expected 'hermes-plugin', got %q", plugins[0].Name)
}
}
// TestListAvailableForWorkspace_RuntimeLookupError falls back to unfiltered
// registry when the runtime lookup returns an error.
func TestListAvailableForWorkspace_RuntimeLookupError(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "my-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(
"name: my-plugin\nversion: 1.0.0\n"), 0644); err != nil {
t.Fatal(err)
}
h := NewPluginsHandler(dir, nil, nil)
// Wire a runtimeLookup that always errors
h = h.WithRuntimeLookup(func(wsID string) (string, error) {
return "", os.ErrNotExist
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: validWSID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+validWSID+"/plugins/available", nil)
h.ListAvailableForWorkspace(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (fallback), got %d", w.Code)
}
var plugins []pluginInfo
if err := json.Unmarshal(w.Body.Bytes(), &plugins); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if len(plugins) != 1 {
t.Errorf("expected 1 plugin (unfiltered on lookup error), got %d", len(plugins))
}
}
// TestListAvailableForWorkspace_WithSQLMock verifies that the workspace
// existence check is bypassed (no SQL query) and the handler only uses
// runtimeLookup for filtering — no DB calls needed.
func TestListAvailableForWorkspace_NoDBCalls(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "test-plugin")
if err := os.Mkdir(pluginDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.yaml"), []byte(
"name: test-plugin\n"), 0644); err != nil {
t.Fatal(err)
}
mock := setupTestDB(t)
h := NewPluginsHandler(dir, nil, nil)
h = h.WithRuntimeLookup(func(wsID string) (string, error) {
return "langgraph", nil
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: validWSID}}
c.Request = httptest.NewRequest("GET", "/workspaces/"+validWSID+"/plugins/available", nil)
h.ListAvailableForWorkspace(c)
// No SQL calls should be made
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// Suppress unused import warning.
var _ = sqlmock.Sqlmock(nil)
@@ -0,0 +1,417 @@
package handlers
import (
"bytes"
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
"github.com/gin-gonic/gin"
)
// validID is a properly-formed UUID used throughout this file.
const validID = "b1b2c3d4-e5f6-7890-abcd-ef1234567890"
// noOpCanCommunicate is a dummy AccessChecker for creating a test Hub.
func noOpCanCommunicate(string, string) bool { return false }
// newTestBroadcastHandler creates a BroadcastHandler with a real in-memory
// broadcaster (Hub + no Redis) so the full broadcast path can be exercised.
func newTestBroadcastHandler(t *testing.T) *BroadcastHandler {
t.Helper()
hub := ws.NewHub(noOpCanCommunicate)
return NewBroadcastHandler(events.NewBroadcaster(hub))
}
// broadcastRequest is a test helper that builds and executes a
// POST /workspaces/:id/broadcast request against the given BroadcastHandler.
func broadcastRequest(h *BroadcastHandler, id, body string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: id}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+id+"/broadcast",
bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
h.Broadcast(c)
return w
}
// ==================== broadcastTruncate pure function ====================
// TestBroadcastTruncate_UnderLimit verifies that strings shorter than the limit
// are returned unchanged.
func TestBroadcastTruncate_UnderLimit(t *testing.T) {
tests := []struct {
input string
max int
}{
{"hello", 10},
{"", 5},
{"hi", 5},
}
for _, tt := range tests {
got := broadcastTruncate(tt.input, tt.max)
if got != tt.input {
t.Errorf("broadcastTruncate(%q, %d): got %q, want %q", tt.input, tt.max, got, tt.input)
}
}
}
// TestBroadcastTruncate_AtLimit verifies that a string exactly at the limit
// is returned unchanged.
func TestBroadcastTruncate_AtLimit(t *testing.T) {
input := "hello"
got := broadcastTruncate(input, 5)
if got != input {
t.Errorf("broadcastTruncate(%q, 5): got %q, want %q", input, got, input)
}
}
// TestBroadcastTruncate_OverLimit verifies that strings longer than the limit
// are truncated to max runes and the ellipsis is appended.
func TestBroadcastTruncate_OverLimit(t *testing.T) {
input := "hello world"
got := broadcastTruncate(input, 5)
// Result is runes[:5] + "…" = "hello" + "…" = 8 chars
if len(got) != 8 {
t.Errorf("broadcastTruncate(%q, 5): got %q (len=%d), want 8 chars", input, got, len(got))
}
if !bytes.HasSuffix([]byte(got), []byte("…")) {
t.Errorf("broadcastTruncate(%q, 5): should end with ellipsis, got %q", input, got)
}
if got[:5] != "hello" {
t.Errorf("broadcastTruncate(%q, 5): first 5 chars should be 'hello', got %q", input, got[:5])
}
}
// TestBroadcastTruncate_Unicode verifies that truncation is rune-based,
// not byte-based, so multi-byte characters are handled correctly.
func TestBroadcastTruncate_Unicode(t *testing.T) {
input := "日本語のテスト"
got := broadcastTruncate(input, 3)
// 3 runes + ellipsis = 4 runes total
if len([]rune(got)) != 4 {
t.Errorf("broadcastTruncate (rune-based): expected 4 runes (3 + ellipsis), got %d: %q", len([]rune(got)), got)
}
if !bytes.HasSuffix([]byte(got), []byte("…")) {
t.Errorf("expected ellipsis suffix, got %q", got)
}
}
// TestBroadcastTruncate_ExactlyOverLimit verifies truncation when input is
// exactly one rune over the limit.
func TestBroadcastTruncate_ExactlyOverLimit(t *testing.T) {
input := "abcdefg"
got := broadcastTruncate(input, 6)
// runes[:6] = "abcdef" (6 chars), ellipsis "…" = 3 UTF-8 bytes (0xE2 0x80 0xA6)
// total = 9 bytes
if len(got) != 9 {
t.Errorf("expected 9 bytes (6 + 3-byte ellipsis), got %d: %q", len(got), got)
}
if got[:6] != "abcdef" {
t.Errorf("expected first 6 chars to be 'abcdef', got %q", got[:6])
}
}
// ==================== Broadcast — input validation (no DB) ====================
// TestBroadcast_InvalidWorkspaceID verifies that a malformed UUID in the path
// returns HTTP 400 before any DB call.
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
mock := setupTestDB(t)
_ = mock
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, "not-a-uuid", `{"message":"hello"}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestBroadcast_MissingMessage verifies that a request with no message field
// returns HTTP 400.
func TestBroadcast_MissingMessage(t *testing.T) {
mock := setupTestDB(t)
_ = mock
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, `{}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestBroadcast_InvalidJSON verifies that malformed JSON returns HTTP 400.
func TestBroadcast_InvalidJSON(t *testing.T) {
mock := setupTestDB(t)
_ = mock
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, "{broken")
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestBroadcast_EmptyMessage verifies that an empty string message is rejected
// by ShouldBindJSON (the "required" tag on the Message field treats "" as absent).
func TestBroadcast_EmptyMessage(t *testing.T) {
mock := setupTestDB(t)
_ = mock
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, `{"message":""}`)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
// ==================== Broadcast — sender not found ====================
// TestBroadcast_WorkspaceNotFound verifies that a sender workspace not in the DB
// returns HTTP 404.
func TestBroadcast_WorkspaceNotFound(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(validID).
WillReturnError(sql.ErrNoRows)
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, `{"message":"hello"}`)
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("sqlmock expectations not met: %v", err)
}
}
// TestBroadcast_BroadcastDisabled verifies that a workspace with
// broadcast_enabled=false returns HTTP 403 with the correct error key.
func TestBroadcast_BroadcastDisabled(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(validID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-workspace", false))
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, `{"message":"hello"}`)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations not met: %v", err)
}
}
// TestBroadcast_SenderQueryError verifies that a DB error on the sender lookup
// returns HTTP 404. Note: QueryRowContext returns ErrNoRows when Scan is
// called on a query that returned no rows; non-ErrNoRows errors are swallowed
// (sql.ErrConnDone → Scan sets err=nil, scanned=false, handler sees !exists → 404).
func TestBroadcast_SenderQueryError(t *testing.T) {
mock := setupTestDB(t)
// QueryRowContext returns ErrNoRows when Scan is called on a query that
// returned no rows — the handler maps this to 404.
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(validID).
WillReturnError(sql.ErrNoRows)
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, `{"message":"hello"}`)
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("sqlmock expectations not met: %v", err)
}
}
// ==================== Broadcast — recipient query ====================
// TestBroadcast_RecipientsQueryError verifies that a DB error on the recipients
// SELECT returns HTTP 500.
func TestBroadcast_RecipientsQueryError(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(validID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-workspace", true))
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(validID).
WillReturnError(sql.ErrConnDone)
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, `{"message":"hello"}`)
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("sqlmock expectations not met: %v", err)
}
}
// ==================== Broadcast — happy path ====================
// TestBroadcast_NoRecipients verifies that broadcasting to zero recipients
// (only the sender exists) still returns HTTP 200 with delivered=0.
func TestBroadcast_NoRecipients(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(validID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-workspace", true))
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(validID).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(validID, "Broadcast sent to 0 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, `{"message":"hello"}`)
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("sqlmock expectations not met: %v", err)
}
}
// TestBroadcast_DeliversToRecipients verifies the full happy path: sender exists,
// one recipient, both activity log entries written, broadcast delivered.
func TestBroadcast_DeliversToRecipients(t *testing.T) {
mock := setupTestDB(t)
const senderID = validID
const recipientID = "c3c3c3c3-d4e5-6789-abcd-ef1234567890"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-workspace", true))
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
// Recipient activity log insert
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(recipientID, senderID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
// Sender activity log insert
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(senderID, "Broadcast sent to 1 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, senderID, `{"message":"hello"}`)
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("sqlmock expectations not met: %v", err)
}
}
// TestBroadcast_MultipleRecipients verifies broadcasting to multiple recipients
// writes an activity log entry for each and delivers to all.
func TestBroadcast_MultipleRecipients(t *testing.T) {
mock := setupTestDB(t)
const senderID = validID
const r1 = "d4d4d4d4-e5f6-7890-abcd-ef1234567890"
const r2 = "e5e5e5e5-f6a7-8901-bcde-f12345678901"
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-workspace", true))
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(r1).AddRow(r2))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(r1, senderID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(r2, senderID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(senderID, "Broadcast sent to 2 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, senderID, `{"message":"hello all"}`)
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("sqlmock expectations not met: %v", err)
}
}
// TestBroadcast_ResponseBody verifies the success response shape.
func TestBroadcast_ResponseBody(t *testing.T) {
mock := setupTestDB(t)
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(validID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-workspace", true))
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(validID).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(validID, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(0, 1))
h := newTestBroadcastHandler(t)
w := broadcastRequest(h, validID, `{"message":"hello"}`)
body := w.Body.String()
if body == "" {
t.Fatal("expected non-empty response body")
}
// Response should contain "sent" and "delivered"
if !bytes.Contains([]byte(body), []byte(`"status"`)) {
t.Errorf("expected response to contain 'status' key, got: %s", body)
}
}