Compare commits

..

1 Commits

Author SHA1 Message Date
fullstack-engineer 65365bc8ec Add TestBroadcast coverage for workspace_broadcast.go
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
CI / Detect changes (pull_request) Successful in 33s
E2E API Smoke Test / detect-changes (pull_request) Failing after 43s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been skipped
Handlers Postgres Integration / detect-changes (pull_request) Successful in 31s
E2E Chat / detect-changes (pull_request) Successful in 50s
Harness Replays / detect-changes (pull_request) Successful in 31s
CI / Canvas (Next.js) (pull_request) Failing after 2m19s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 27s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 38s
qa-review / approved (pull_request) Successful in 26s
security-review / approved (pull_request) Successful in 24s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m49s
Harness Replays / Harness Replays (pull_request) Successful in 24s
E2E Chat / E2E Chat (pull_request) Failing after 41s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 15s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Failing after 4m40s
CI / Platform (Go) (pull_request) Failing after 20m10s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
CI / all-required (pull_request) Failing after 1s
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) acked: 7/7
sop-checklist / na-declarations (pull_request) N/A: (none)
Covers all branches of the Broadcast handler:
- broadcastTruncate: 6 sub-cases (empty, under/over/unicode limits)
- Validation: invalid UUID, missing message, malformed JSON
- Auth: workspace not found, lookup error, broadcast disabled (403)
- DB errors: recipient query error, rows.Err error
- Success: one recipient, no recipients, multiple recipients
- Resiliency: recipient insert failure continues with 200; sender log
  failure also returns 200 (logged only)
2026-05-16 14:36:18 +00:00
@@ -4,405 +4,407 @@ 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) {
// "日本語" 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()) {
// 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()
// broadcastTestUUID 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.
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
WithArgs(broadcastTestUUID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("test-workspace", true))
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"})) // 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)").
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
WithArgs(broadcastTestUUID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("test-workspace", true))
// 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.
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
WithArgs(broadcastTestUUID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("sender-workspace", true))
// 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"
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
WithArgs(broadcastTestUUID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("solo-workspace", true))
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)").
// 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"
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
WithArgs(broadcastTestUUID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("broadcaster", true))
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)").
// 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())
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
handler.Broadcast(c)
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"])
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").
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
WithArgs(broadcastTestUUID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("broadcaster", true))
// 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))
// 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())
// 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)
// Even though the sender log failed, response is still 200.
// 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"}`)
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces WHERE id = $1 AND status != 'removed'").
WithArgs(broadcastTestUUID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("broadcaster", true))
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)
}
}