Compare commits

..

1 Commits

Author SHA1 Message Date
fullstack-engineer 1677e2967b test(handlers): add Uninstall 503 coverage for plugins_install.go
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 24s
CI / Detect changes (pull_request) Successful in 30s
E2E Chat / detect-changes (pull_request) Successful in 42s
E2E API Smoke Test / detect-changes (pull_request) Successful in 44s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 26s
Harness Replays / detect-changes (pull_request) Successful in 18s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 31s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 32s
qa-review / approved (pull_request) Successful in 30s
security-review / approved (pull_request) Successful in 32s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m47s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
CI / Python Lint & Test (pull_request) Successful in 8s
E2E Chat / E2E Chat (pull_request) Failing after 33s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m49s
Harness Replays / Harness Replays (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 11s
CI / Canvas (Next.js) (pull_request) Successful in 23m13s
CI / Platform (Go) (pull_request) Successful in 25m28s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 8m1s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
gate-check-v3 / gate-check (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Successful in 3s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat
sop-checklist / na-declarations (pull_request) N/A: (none)
audit-force-merge / audit (pull_request) Successful in 8s
Adds TestPluginUninstall_ContainerNotRunning_Returns503 which exercises
the 503 path where neither a local Docker container nor a SaaS
instance-id dispatch resolves. The handler must return "workspace
container not running" — not a generic 500 or a misleading 422.

All other paths in plugins_install.go (Install, Download, allowlist,
external-runtime guard) are already covered by the existing test suite
in plugins_test.go, plugins_install_external_test.go, and
org_plugin_allowlist_test.go.

Closes #1377

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 19:25:43 +00:00
6 changed files with 53 additions and 912 deletions
@@ -1,102 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for design-tokens.ts constant exports.
*
* STATUS_CONFIG is tested here directly rather than inside
* statusDotClass.test.ts so the constant's full shape (dot, glow, label,
* bar per key) is explicitly asserted — not just indirectly via the
* statusDotClass helper that consumes its .dot field.
*/
import { describe, it, expect } from "vitest";
import { STATUS_CONFIG } from "../design-tokens";
const ALL_STATUS_KEYS = [
"online",
"offline",
"paused",
"degraded",
"failed",
"provisioning",
"not_configured",
] as const;
describe("STATUS_CONFIG", () => {
it("has exactly the expected status keys and no extras", () => {
const actual = Object.keys(STATUS_CONFIG).sort();
const expected = [...ALL_STATUS_KEYS].sort();
expect(actual).toEqual(expected);
});
it("every entry has dot, glow, label, and bar fields", () => {
for (const key of ALL_STATUS_KEYS) {
const entry = STATUS_CONFIG[key];
expect(entry, `entry for "${key}"`).toHaveProperty("dot");
expect(entry, `entry for "${key}"`).toHaveProperty("glow");
expect(entry, `entry for "${key}"`).toHaveProperty("label");
expect(entry, `entry for "${key}"`).toHaveProperty("bar");
}
});
it("dot, glow, label, bar are all non-empty strings", () => {
for (const key of ALL_STATUS_KEYS) {
const entry = STATUS_CONFIG[key];
for (const field of ["dot", "glow", "label", "bar"] as const) {
expect(typeof entry[field], `"${key}".${field}`).toBe("string");
// label must be non-empty; others may be empty (e.g. offline.glow = "").
if (field === "label") {
expect(entry[field].length, `"${key}".${field}`).toBeGreaterThan(0);
}
}
}
});
it('online: dot is emerald, glow is set, label is "Online"', () => {
expect(STATUS_CONFIG.online.dot).toBe("bg-emerald-400");
expect(STATUS_CONFIG.online.glow).toBe("shadow-emerald-400/50");
expect(STATUS_CONFIG.online.label).toBe("Online");
expect(STATUS_CONFIG.online.bar).toBe("from-emerald-500/20 to-transparent");
});
it('offline: dot is zinc, glow is empty, label is "Offline"', () => {
expect(STATUS_CONFIG.offline.dot).toBe("bg-zinc-500");
expect(STATUS_CONFIG.offline.glow).toBe("");
expect(STATUS_CONFIG.offline.label).toBe("Offline");
expect(STATUS_CONFIG.offline.bar).toBe("from-zinc-600/10 to-transparent");
});
it('paused: dot is indigo, label is "Paused"', () => {
expect(STATUS_CONFIG.paused.dot).toBe("bg-indigo-400");
expect(STATUS_CONFIG.paused.glow).toBe("");
expect(STATUS_CONFIG.paused.label).toBe("Paused");
});
it('degraded: dot is amber with glow, label is "Degraded"', () => {
expect(STATUS_CONFIG.degraded.dot).toBe("bg-amber-400");
expect(STATUS_CONFIG.degraded.glow).toBe("shadow-amber-400/50");
expect(STATUS_CONFIG.degraded.label).toBe("Degraded");
});
it('failed: dot is red with glow, label is "Failed"', () => {
expect(STATUS_CONFIG.failed.dot).toBe("bg-red-400");
expect(STATUS_CONFIG.failed.glow).toBe("shadow-red-400/50");
expect(STATUS_CONFIG.failed.label).toBe("Failed");
});
it('provisioning: dot is sky with pulse animation, label is "Starting"', () => {
expect(STATUS_CONFIG.provisioning.dot).toBe("bg-sky-400 motion-safe:animate-pulse");
expect(STATUS_CONFIG.provisioning.glow).toBe("shadow-sky-400/50");
expect(STATUS_CONFIG.provisioning.label).toBe("Starting");
});
it('not_configured: dot is amber-300 with glow, label is "Not configured"', () => {
expect(STATUS_CONFIG.not_configured.dot).toBe("bg-amber-300");
expect(STATUS_CONFIG.not_configured.glow).toBe("shadow-amber-300/50");
expect(STATUS_CONFIG.not_configured.label).toBe("Not configured");
});
it("is a frozen static map — same key always returns same object reference", () => {
for (const key of ALL_STATUS_KEYS) {
expect(STATUS_CONFIG[key]).toBe(STATUS_CONFIG[key]);
}
});
});
-60
View File
@@ -1,60 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for theme.ts — cssVar() function and ColorToken type.
*/
import { describe, it, expect } from "vitest";
import { cssVar, type ColorToken } from "../theme";
describe("cssVar", () => {
it("wraps each known token in a var() reference", () => {
const tokens: ColorToken[] = [
"surface",
"surface-elevated",
"surface-sunken",
"surface-card",
"line",
"line-soft",
"ink",
"ink-mid",
"ink-soft",
"accent",
"accent-strong",
"warm",
"good",
"bad",
"bg",
"bg-elev",
"bg-card",
"line-strong",
"ink-mute",
"ink-dim",
"accent-dim",
"plasma",
"warn",
];
for (const token of tokens) {
expect(cssVar(token)).toBe(`var(--color-${token})`);
}
});
it("is a pure function — same token always returns same value", () => {
for (let i = 0; i < 5; i++) {
expect(cssVar("accent")).toBe("var(--color-accent)");
expect(cssVar("surface")).toBe("var(--color-surface)");
expect(cssVar("good")).toBe("var(--color-good)");
}
});
it("handles hyphenated tokens correctly", () => {
expect(cssVar("surface-elevated")).toBe("var(--color-surface-elevated)");
expect(cssVar("line-soft")).toBe("var(--color-line-soft)");
expect(cssVar("ink-mute")).toBe("var(--color-ink-mute)");
});
it("produces a value usable as an inline style prop value", () => {
const result = cssVar("accent");
expect(typeof result).toBe("string");
expect(result.startsWith("var(--color-")).toBe(true);
expect(result.endsWith(")")).toBe(true);
});
});
@@ -0,0 +1,53 @@
package handlers
// plugins_install_test.go — additional coverage for plugins_install.go.
//
// Gaps filled vs. existing test files:
// - plugins_install_external_test.go: Install + Uninstall 422 (external runtime) ✓ covered
// - plugins_test.go: Install 400 (missing source, invalid body, etc.) ✓ covered
// Uninstall 400 (invalid plugin name, empty name) ✓ covered
// Download auth gate ✓ covered
// - org_import_helpers_test.go: countWorkspaces, envRequirementKey, sanitizeEnvMembers,
// flattenAndSortRequirements, collectOrgEnv ✓ covered
//
// New test added here:
// - Uninstall 503: container not running, no SaaS dispatch.
//
// NOTE: validateWorkspaceID is not called inside the Install/Uninstall handlers.
// UUID validation is the responsibility of the WorkspaceAuth middleware, so no
// 400 test is needed here for UUID format.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// TestPluginUninstall_ContainerNotRunning_Returns503 exercises the 503 path
// where neither a local Docker container nor a SaaS instance-id dispatch
// resolves. The handler must return "workspace container not running" — NOT a
// generic 500 or a misleading 422 (external-runtime) message.
func TestPluginUninstall_ContainerNotRunning_Returns503(t *testing.T) {
// No docker client + no instance-id lookup → falls through to 503.
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{
{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"},
{Key: "name", Value: "some-plugin"},
}
c.Request = httptest.NewRequest("DELETE",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/plugins/some-plugin", nil)
h.Uninstall(c)
require.Equal(t, http.StatusServiceUnavailable, w.Code)
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
require.Equal(t, "workspace container not running", body["error"])
}
@@ -1,55 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// ListSources is the only exported function in plugins_sources.go.
// It calls h.sources.Schemes() and returns the result verbatim,
// so the test verifies the handler correctly serialises whatever
// the real registry provides.
func TestListSources_ReturnsSchemes(t *testing.T) {
// Use a real handler — the registry is deterministic (local + github).
h := NewPluginsHandler(t.TempDir(), nil, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body struct {
Schemes []string `json:"schemes"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
// The default registry registers local + github resolvers.
if len(body.Schemes) < 1 {
t.Fatalf("expected at least 1 scheme, got %d: %v", len(body.Schemes), body.Schemes)
}
// Verify stability — same call always returns same result.
w2 := httptest.NewRecorder()
c2, _ := gin.CreateTestContext(w2)
c2.Request = httptest.NewRequest("GET", "/plugins/sources", nil)
h.ListSources(c2)
var body2 struct {
Schemes []string `json:"schemes"`
}
json.Unmarshal(w2.Body.Bytes(), &body2)
if len(body.Schemes) != len(body2.Schemes) {
t.Errorf("Schemes() is not stable: first=%v, second=%v", body.Schemes, body2.Schemes)
}
}
@@ -1,297 +0,0 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
func setupAbilitiesTest(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prev := db.DB
db.DB = mockDB
return mock, func() {
db.DB = prev
mockDB.Close()
}
}
func TestPatchAbilities_InvalidWorkspaceID_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "not-a-valid-uuid"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/not-a-valid-uuid/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "invalid workspace ID" {
t.Errorf("expected 'invalid workspace ID', got %q", body["error"])
}
}
func TestPatchAbilities_EmptyBody_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "at least one ability field required" {
t.Errorf("expected 'at least one ability field required', got %q", body["error"])
}
}
func TestPatchAbilities_InvalidJSON_Returns400(t *testing.T) {
_, cleanup := setupAbilitiesTest(t)
defer cleanup()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{invalid json}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "invalid request body" {
t.Errorf("expected 'invalid request body', got %q", body["error"])
}
}
func TestPatchAbilities_WorkspaceNotFound_Returns404(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(sql.ErrNoRows)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_WorkspaceDBError_Returns404(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(errors.New("connection refused"))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBroadcastEnabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_UpdateTalkToUserEnabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"talk_to_user_enabled":true}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBothAbilities_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", true).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE workspaces SET talk_to_user_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", false).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":true,"talk_to_user_enabled":false}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "updated" {
t.Errorf("expected status=updated, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestPatchAbilities_UpdateBroadcastDisabled_Returns200(t *testing.T) {
mock, cleanup := setupAbilitiesTest(t)
defer cleanup()
mock.ExpectQuery("SELECT EXISTS").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectExec("UPDATE workspaces SET broadcast_enabled").
WithArgs("550e8400-e29b-41d4-a716-446655440000", false).
WillReturnResult(sqlmock.NewResult(0, 1))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("PATCH",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/abilities",
bytes.NewBufferString(`{"broadcast_enabled":false}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
PatchAbilities(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
@@ -1,398 +0,0 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
)
// -------------------------------------------------------------------------- //
// broadcastTruncate
// -------------------------------------------------------------------------- //
func TestBroadcastTruncate_ShortString_ReturnsUnmodified(t *testing.T) {
result := broadcastTruncate("hello", 10)
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}
func TestBroadcastTruncate_ExactlyMaxLength_ReturnsUnmodified(t *testing.T) {
result := broadcastTruncate("hello", 5)
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}
func TestBroadcastTruncate_ExceedsMaxLength_TruncatesWithEllipsis(t *testing.T) {
result := broadcastTruncate("hello world", 5)
if result != "hello…" {
t.Errorf("expected 'hello…', got %q", result)
}
}
func TestBroadcastTruncate_Unicode_TruncatesAtRuneBoundary(t *testing.T) {
result := broadcastTruncate("日本語テスト", 2)
if result != "日本…" {
t.Errorf("expected '日本…', got %q", result)
}
}
// -------------------------------------------------------------------------- //
// BroadcastHandler
// -------------------------------------------------------------------------- //
func setupBroadcastTest(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prev := db.DB
db.DB = mockDB
return mock, func() {
db.DB = prev
mockDB.Close()
}
}
func TestBroadcast_InvalidWorkspaceID_Returns400(t *testing.T) {
_, cleanup := setupBroadcastTest(t)
defer cleanup()
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
c.Request = httptest.NewRequest("POST", "/workspaces/not-a-uuid/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "invalid workspace ID" {
t.Errorf("expected 'invalid workspace ID', got %q", body["error"])
}
}
func TestBroadcast_MissingMessage_Returns400(t *testing.T) {
_, cleanup := setupBroadcastTest(t)
defer cleanup()
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "message is required" {
t.Errorf("expected 'message is required', got %q", body["error"])
}
}
func TestBroadcast_WorkspaceNotFound_Returns404(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(sql.ErrNoRows)
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_BroadcastDisabled_Returns403(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-agent", false))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d: %s", w.Code, w.Body.String())
}
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
if body["error"] != "broadcast_disabled" {
t.Errorf("expected error='broadcast_disabled', got %v", body)
}
if _, ok := body["hint"]; !ok {
t.Errorf("expected hint field in 403 body, got %v", body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_RecipientQueryFails_Returns500(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-agent", true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnError(errors.New("connection refused"))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_NoRecipients_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-agent", true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs("550e8400-e29b-41d4-a716-446655440000", "Broadcast sent to 0 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "550e8400-e29b-41d4-a716-446655440000"}}
c.Request = httptest.NewRequest("POST",
"/workspaces/550e8400-e29b-41d4-a716-446655440000/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if body["status"] != "sent" {
t.Errorf("expected status=sent, got %v", body)
}
if int(body["delivered"].(float64)) != 0 {
t.Errorf("expected delivered=0, got %v", body["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_DeliversToOneRecipient_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
senderID := "550e8400-e29b-41d4-a716-446655440000"
recipientID := "660e8400-e29b-41d4-a716-446655440001"
senderName := "test-agent"
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow(senderName, true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(senderID, "Broadcast sent to 1 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
c.Request = httptest.NewRequest("POST",
"/workspaces/"+senderID+"/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if int(body["delivered"].(float64)) != 1 {
t.Errorf("expected delivered=1, got %v", body["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_RecipientInsertFails_Continues_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
senderID := "550e8400-e29b-41d4-a716-446655440000"
recipientID := "660e8400-e29b-41d4-a716-446655440001"
senderName := "test-agent"
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow(senderName, true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
WillReturnError(errors.New("connection refused"))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(senderID, "Broadcast sent to 0 workspace(s)").
WillReturnResult(sqlmock.NewResult(0, 1))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
c.Request = httptest.NewRequest("POST",
"/workspaces/"+senderID+"/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if int(body["delivered"].(float64)) != 0 {
t.Errorf("expected delivered=0 (failed inserts don't count), got %v", body["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
func TestBroadcast_SenderLogFails_StillReturns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
senderID := "550e8400-e29b-41d4-a716-446655440000"
recipientID := "660e8400-e29b-41d4-a716-446655440001"
senderName := "test-agent"
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow(senderName, true))
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != ").
WithArgs(senderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(recipientID))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(senderID, "Broadcast sent to 1 workspace(s)").
WillReturnError(errors.New("connection refused"))
h := NewBroadcastHandler(newTestBroadcaster())
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: senderID}}
c.Request = httptest.NewRequest("POST",
"/workspaces/"+senderID+"/broadcast",
bytes.NewBufferString(`{"message":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Request = c.Request.WithContext(context.Background())
h.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var body map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &body)
if int(body["delivered"].(float64)) != 1 {
t.Errorf("expected delivered=1, got %v", body["delivered"])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}