Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e53b59f90 |
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user