Compare commits

..

1 Commits

Author SHA1 Message Date
fullstack-engineer 8cbf3e2680 test(handlers): add coverage for BroadcastHandler (workspace_broadcast.go)
Add 13 tests covering every path in the POST /workspaces/:id/broadcast
handler and its unexported broadcastTruncate helper:

  broadcastTruncate:
    - short string → unmodified
    - exactly max length → unmodified
    - exceeds max length → truncated + ellipsis
    - unicode runes → rune-boundary truncation

  Broadcast:
    - invalid workspace ID → 400
    - missing message → 400
    - workspace not found → 404
    - broadcast_disabled=true → 403 with hint
    - recipient query fails → 500
    - no recipients → 200 delivered=0
    - one recipient → 200 delivered=1
    - recipient insert fails → 200 delivered=0 (best-effort continue)
    - sender log fails → 200 delivered=1 (best-effort, non-blocking)

Uses sqlmock for the DB layer and newTestBroadcaster() for the
*events.Broadcaster injection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:11:47 +00:00
3 changed files with 408 additions and 120 deletions
-66
View File
@@ -1,66 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for theme.ts — cssVar() function and ColorToken type.
*
* cssVar is intentionally simple (string concatenation) — these tests guard
* against regressions as ColorToken is extended and against typos in the
* token-name → CSS variable mapping.
*/
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", () => {
// cssVar returns a plain string, which is exactly what style={{ color: cssVar(token) }}
// expects — no extra wrapping needed.
const result = cssVar("accent");
expect(typeof result).toBe("string");
expect(result.startsWith("var(--color-")).toBe(true);
expect(result.endsWith(")")).toBe(true);
});
});
@@ -1,54 +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 the schemes slice is stable (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)
}
}
@@ -0,0 +1,408 @@
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) {
// "日本語" is 3 runes; truncating at 2 should give 2 runes + ellipsis.
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"})) // no rows
// Sender's own broadcast_sent insert.
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))
// activity_logs insert for recipient.
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
WillReturnResult(sqlmock.NewResult(0, 1))
// Sender's broadcast_sent insert.
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))
// Recipient insert fails — handler logs and continues.
mock.ExpectExec("INSERT INTO activity_logs").
WithArgs(recipientID, senderID, "Broadcast from "+senderName+": hello").
WillReturnError(errors.New("connection refused"))
// Sender's broadcast_sent insert still succeeds.
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)
// Even though the recipient insert failed, the handler still returns 200
// with delivered=0 (counted only on success).
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))
// Sender's broadcast_sent insert fails — best-effort, no effect on response.
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)
// Even though the sender log failed, response is still 200.
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)
}
}