Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a9efbf8c3 |
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,395 +4,422 @@ 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/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
|
||||
"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()) {
|
||||
// setupBroadcastDB uses QueryMatcherEqual so SQL strings with quoted literals
|
||||
// (e.g. status != 'removed') are compared verbatim, not as regex.
|
||||
func setupBroadcastDB(t *testing.T) sqlmock.Sqlmock {
|
||||
t.Helper()
|
||||
mockDB, mock, err := sqlmock.New()
|
||||
mockDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create sqlmock: %v", err)
|
||||
}
|
||||
prev := db.DB
|
||||
prevDB := db.DB
|
||||
db.DB = mockDB
|
||||
return mock, func() {
|
||||
db.DB = prev
|
||||
mockDB.Close()
|
||||
}
|
||||
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
|
||||
return mock
|
||||
}
|
||||
|
||||
func TestBroadcast_InvalidWorkspaceID_Returns400(t *testing.T) {
|
||||
_, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
// validUUID is a properly formatted test UUID.
|
||||
const broadcastTestUUID = "bbbbbbbb-0001-0001-0001-000000000001"
|
||||
|
||||
h := NewBroadcastHandler(newTestBroadcaster())
|
||||
// buildBroadcastCtx creates a gin.Context wired for POST /workspaces/:id/broadcast.
|
||||
func buildBroadcastCtx(id, body string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
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())
|
||||
req := httptest.NewRequest(http.MethodPost, "/workspaces/"+id+"/broadcast", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.Request = req.WithContext(context.Background())
|
||||
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||
return c, w
|
||||
}
|
||||
|
||||
h.Broadcast(c)
|
||||
// ─── Pure function ────────────────────────────────────────────────────────────
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
func TestBroadcastTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
s string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"empty string", "", 10, ""},
|
||||
{"under limit", "hello", 10, "hello"},
|
||||
{"exactly at limit", "hello", 5, "hello"},
|
||||
{"over limit", "hello world", 5, "hello…"},
|
||||
{"unicode over limit", "こんにちは世界", 5, "こんにちは…"},
|
||||
{"ascii over limit", "abcdefghij", 5, "abcde…"},
|
||||
}
|
||||
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"])
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := broadcastTruncate(tc.s, tc.max)
|
||||
if got != tc.want {
|
||||
t.Errorf("broadcastTruncate(%q, %d) = %q; want %q", tc.s, tc.max, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// ─── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
|
||||
c, w := buildBroadcastCtx("not-a-uuid", `{"message":"hello"}`)
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.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"])
|
||||
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_WorkspaceNotFound_Returns404(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{}`)
|
||||
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
|
||||
WithArgs("550e8400-e29b-41d4-a716-446655440000").
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MalformedJSON(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `not json`)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("want 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Auth / Authz ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_WorkspaceNotFound(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
// Workspace lookup returns no rows.
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
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)
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
t.Errorf("want 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_BroadcastDisabled_Returns403(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
func TestBroadcast_WorkspaceLookupQueryError(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
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))
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
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())
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
h.Broadcast(c)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_BroadcastDisabled(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
// Workspace found but broadcast_enabled=false.
|
||||
rows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("test-workspace", false)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.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)
|
||||
t.Errorf("want 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_RecipientQueryFails_Returns500(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
// ─── DB error paths ───────────────────────────────────────────────────────────
|
||||
|
||||
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"))
|
||||
func TestBroadcast_RecipientQueryError(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
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())
|
||||
// Workspace lookup succeeds with broadcast_enabled=true.
|
||||
rows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("test-workspace", true)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.Broadcast(c)
|
||||
// Recipient query fails.
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != $1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
t.Errorf("want 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_NoRecipients_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
func TestBroadcast_RecipientRowsError(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
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)").
|
||||
rows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("test-workspace", true)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
// Recipient query succeeds but rows.Err() fails.
|
||||
badRows := sqlmock.NewRows([]string{"id"}).AddRow("ws-2").RowError(0, sql.ErrConnDone)
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != $1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(badRows)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("want 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Success paths ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestBroadcast_Success_OneRecipient(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello world"}`)
|
||||
|
||||
// Workspace lookup.
|
||||
wsRows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("sender-workspace", true)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(wsRows)
|
||||
|
||||
// Recipient query: one recipient.
|
||||
recipRows := sqlmock.NewRows([]string{"id"}).AddRow("ws-recipient-1")
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != $1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
// Activity log insert for recipient.
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs("ws-recipient-1", broadcastTestUUID, sqlmock.AnyArg()).
|
||||
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())
|
||||
// Activity log insert for sender (broadcast_sent).
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Broadcast(c)
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.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"])
|
||||
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_DeliversToOneRecipient_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
func TestBroadcast_Success_NoRecipients(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
senderID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
recipientID := "660e8400-e29b-41d4-a716-446655440001"
|
||||
senderName := "test-agent"
|
||||
wsRows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("solo-workspace", true)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(wsRows)
|
||||
|
||||
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)").
|
||||
// No recipients.
|
||||
recipRows := sqlmock.NewRows([]string{"id"})
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != $1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
// Activity log insert for sender (broadcast_sent).
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
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)
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.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"])
|
||||
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_RecipientInsertFails_Continues_Returns200(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
func TestBroadcast_Success_MultipleRecipients(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
senderID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
recipientID := "660e8400-e29b-41d4-a716-446655440001"
|
||||
senderName := "test-agent"
|
||||
wsRows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("broadcaster", true)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(wsRows)
|
||||
|
||||
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)").
|
||||
// Three recipients.
|
||||
recipRows := sqlmock.NewRows([]string{"id"}).
|
||||
AddRow("ws-1").AddRow("ws-2").AddRow("ws-3")
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != $1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
// Each recipient gets a broadcast_receive log.
|
||||
for _, rid := range []string{"ws-1", "ws-2", "ws-3"} {
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs(rid, broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
|
||||
// Sender log.
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
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)
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.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"])
|
||||
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_SenderLogFails_StillReturns200(t *testing.T) {
|
||||
mock, cleanup := setupBroadcastTest(t)
|
||||
defer cleanup()
|
||||
// ─── Recipient insert failure (logged, continues) ─────────────────────────────
|
||||
|
||||
senderID := "550e8400-e29b-41d4-a716-446655440000"
|
||||
recipientID := "660e8400-e29b-41d4-a716-446655440001"
|
||||
senderName := "test-agent"
|
||||
func TestBroadcast_RecipientInsertError_ContinuesAndSucceeds(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
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").
|
||||
wsRows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("broadcaster", true)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(wsRows)
|
||||
|
||||
// Two recipients.
|
||||
recipRows := sqlmock.NewRows([]string{"id"}).
|
||||
AddRow("ws-1").AddRow("ws-2")
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != $1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
// First recipient insert fails (logged, continues).
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs("ws-1", broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
// Second recipient insert succeeds.
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs("ws-2", broadcastTestUUID, sqlmock.AnyArg()).
|
||||
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())
|
||||
// Sender log.
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Broadcast(c)
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
// Handler returns 200 even though one insert failed — it logs and continues.
|
||||
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"])
|
||||
t.Errorf("want 200 despite insert error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sender activity log insert failure (logged, still 200) ───────────────────
|
||||
|
||||
func TestBroadcast_SenderLogInsertError_Still200(t *testing.T) {
|
||||
mock := setupBroadcastDB(t)
|
||||
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
|
||||
|
||||
wsRows := sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
|
||||
AddRow("broadcaster", true)
|
||||
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(wsRows)
|
||||
|
||||
recipRows := sqlmock.NewRows([]string{"id"}).AddRow("ws-1")
|
||||
mock.ExpectQuery("SELECT id FROM workspaces WHERE status != 'removed' AND id != $1").
|
||||
WithArgs(broadcastTestUUID).
|
||||
WillReturnRows(recipRows)
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, summary, status) VALUES ($1, 'broadcast_receive', 'broadcast', $2, $3, 'ok')").
|
||||
WithArgs("ws-1", broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// Sender log fails — but handler still returns 200 (logged only).
|
||||
mock.ExpectExec("INSERT INTO activity_logs (workspace_id, activity_type, method, summary, status) VALUES ($1, 'broadcast_sent', 'broadcast', $2, 'ok')").
|
||||
WithArgs(broadcastTestUUID, sqlmock.AnyArg()).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200 despite sender log error, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet mock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user