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
@@ -1,277 +1,408 @@
package handlers
// workspace_broadcast_test.go — coverage for workspace_broadcast.go.
//
// Covered handlers:
// - BroadcastHandler.Broadcast POST /workspaces/:id/broadcast
// - broadcastTruncate pure function
//
// DB reads are mocked via sqlmock. The *events.Broadcaster is injected
// as the real no-op test broadcaster so BroadcastOnly() is safe in tests.
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// ─── broadcastTruncate ─────────────────────────────────────────────────────────
// -------------------------------------------------------------------------- //
// broadcastTruncate
// -------------------------------------------------------------------------- //
func TestBroadcastTruncate_LenBelowMax_ReturnsFullString(t *testing.T) {
func TestBroadcastTruncate_ShortString_ReturnsUnmodified(t *testing.T) {
result := broadcastTruncate("hello", 10)
require.Equal(t, "hello", result)
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}
func TestBroadcastTruncate_LenEqualMax_ReturnsFullString(t *testing.T) {
func TestBroadcastTruncate_ExactlyMaxLength_ReturnsUnmodified(t *testing.T) {
result := broadcastTruncate("hello", 5)
require.Equal(t, "hello", result)
if result != "hello" {
t.Errorf("expected 'hello', got %q", result)
}
}
func TestBroadcastTruncate_LenAboveMax_TruncatesWithEllipsis(t *testing.T) {
func TestBroadcastTruncate_ExceedsMaxLength_TruncatesWithEllipsis(t *testing.T) {
result := broadcastTruncate("hello world", 5)
require.Equal(t, "hello…", result)
}
func TestBroadcastTruncate_EmptyString_ReturnsEmpty(t *testing.T) {
result := broadcastTruncate("", 5)
require.Equal(t, "", result)
if result != "hello…" {
t.Errorf("expected 'hello…', got %q", result)
}
}
func TestBroadcastTruncate_Unicode_TruncatesAtRuneBoundary(t *testing.T) {
// "日本語" is 3 runes; truncating at max=2 should give 2 runes + ellipsis.
result := broadcastTruncate("日本語abcdef", 2)
require.Equal(t, "日本…", result)
// "日本語" is 3 runes; truncating at 2 should give 2 runes + ellipsis.
result := broadcastTruncate("日本語テスト", 2)
if result != "日本…" {
t.Errorf("expected '日本…', got %q", result)
}
}
// ─── Broadcast handler ────────────────────────────────────────────────────────
// -------------------------------------------------------------------------- //
// BroadcastHandler
// -------------------------------------------------------------------------- //
// Valid UUIDs used throughout the test suite.
const (
testSenderID = "00000000-0000-0000-0000-000000000001"
testRecipient1 = "00000000-0000-0000-0000-000000000002"
testRecipient2 = "00000000-0000-0000-0000-000000000003"
)
func setupBroadcastCtx(t *testing.T, body string) (*BroadcastHandler, sqlmock.Sqlmock, *httptest.ResponseRecorder, *gin.Context) {
func setupBroadcastTest(t *testing.T) (sqlmock.Sqlmock, func()) {
t.Helper()
mockDB, mock, err := sqlmock.New()
require.NoError(t, err)
prevDB := db.DB
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prev := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: testSenderID}}
c.Request = httptest.NewRequest("POST", "/workspaces/"+testSenderID+"/broadcast", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
h := NewBroadcastHandler(newTestBroadcaster())
return h, mock, w, c
return mock, func() {
db.DB = prev
mockDB.Close()
}
}
func TestBroadcast_InvalidWorkspaceID_Returns400(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/workspaces/not-a-uuid/broadcast", nil)
c.Params = gin.Params{{Key: "id", Value: "not-a-uuid"}}
_, 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)
require.Equal(t, http.StatusBadRequest, w.Code)
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)
require.Contains(t, body["error"], "invalid workspace ID")
if body["error"] != "invalid workspace ID" {
t.Errorf("expected 'invalid workspace ID', got %q", body["error"])
}
}
func TestBroadcast_MissingMessage_Returns400(t *testing.T) {
h, _, w, c := setupBroadcastCtx(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())
// ShouldBindJSON fails first — no DB query expected.
h.Broadcast(c)
require.Equal(t, http.StatusBadRequest, w.Code)
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)
require.Equal(t, "message is required", body["error"])
if body["error"] != "message is required" {
t.Errorf("expected 'message is required', got %q", body["error"])
}
}
func TestBroadcast_WorkspaceNotFound_Returns404(t *testing.T) {
h, mock, w, c := setupBroadcastCtx(t, `{"message":"hello"}`)
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(testSenderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"})) // empty
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)
require.Equal(t, http.StatusNotFound, w.Code)
var body map[string]string
json.Unmarshal(w.Body.Bytes(), &body)
require.Equal(t, "workspace not found", body["error"])
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) {
h, mock, w, c := setupBroadcastCtx(t, `{"message":"hello"}`)
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(testSenderID).
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-ws", false))
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)
require.Equal(t, http.StatusForbidden, w.Code)
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)
require.Equal(t, "broadcast_disabled", body["error"])
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_RecipientQueryError_Returns500(t *testing.T) {
h, mock, w, c := setupBroadcastCtx(t, `{"message":"hello"}`)
func TestBroadcast_RecipientQueryFails_Returns500(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(testSenderID).
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-ws", true))
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(testSenderID).
WillReturnError(context.DeadlineExceeded)
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)
require.Equal(t, http.StatusInternalServerError, w.Code)
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_Success_Returns200AndDeliveredCount(t *testing.T) {
h, mock, w, c := setupBroadcastCtx(t, `{"message":"hello world"}`)
func TestBroadcast_NoRecipients_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(testSenderID).
mock.ExpectQuery("SELECT name, broadcast_enabled FROM workspaces").
WithArgs("550e8400-e29b-41d4-a716-446655440000").
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-ws", true))
// Two recipients.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(testSenderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(testRecipient1).AddRow(testRecipient2))
// Activity log insert per recipient.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(testRecipient1, testSenderID, "Broadcast from test-ws: hello world").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(testRecipient2, testSenderID, "Broadcast from test-ws: hello world").
WillReturnResult(sqlmock.NewResult(1, 1))
// Sender's own log.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(testSenderID, "Broadcast sent to 2 workspace(s)").
WillReturnResult(sqlmock.NewResult(1, 1))
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)
require.Equal(t, http.StatusOK, w.Code)
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)
require.Equal(t, "sent", body["status"])
require.Equal(t, float64(2), body["delivered"])
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_NoRecipients_ReturnsZeroDelivered(t *testing.T) {
h, mock, w, c := setupBroadcastCtx(t, `{"message":"hello"}`)
func TestBroadcast_DeliversToOneRecipient_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(testSenderID).
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("solo-ws", true))
// No other workspaces.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(testSenderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
// Sender log still fires.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(testSenderID, "Broadcast sent to 0 workspace(s)").
WillReturnResult(sqlmock.NewResult(1, 1))
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)
require.Equal(t, http.StatusOK, w.Code)
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)
require.Equal(t, "sent", body["status"])
require.Equal(t, float64(0), body["delivered"])
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_ActivityLogInsertFails_StillReturns200(t *testing.T) {
// Sender's own activity log is best-effort; a DB error is logged but
// does NOT fail the HTTP response.
h, mock, w, c := setupBroadcastCtx(t, `{"message":"hello"}`)
func TestBroadcast_RecipientInsertFails_Continues_Returns200(t *testing.T) {
mock, cleanup := setupBroadcastTest(t)
defer cleanup()
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(testSenderID).
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("test-ws", true))
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(testSenderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
// Recipient insert succeeds.
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnResult(sqlmock.NewResult(1, 1))
// Sender log FAILS — handler logs but still returns 200.
mock.ExpectExec(`INSERT INTO activity_logs`).
WillReturnError(context.DeadlineExceeded)
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)
require.Equal(t, http.StatusOK, w.Code) // NOT 500
}
func TestBroadcast_RecipientInsertFails_ContinuesAndCountsOthers(t *testing.T) {
// A recipient-level insert failure is logged; the handler continues
// delivering to remaining recipients and reports the delivered count.
h, mock, w, c := setupBroadcastCtx(t, `{"message":"hello"}`)
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
WithArgs(testSenderID).
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).
AddRow("test-ws", true))
// Two recipients.
mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`).
WithArgs(testSenderID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(testRecipient1).AddRow(testRecipient2))
// testRecipient1 insert FAILS — logged, handler continues.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(testRecipient1, testSenderID, "Broadcast from test-ws: hello").
WillReturnError(context.DeadlineExceeded)
// testRecipient2 insert succeeds.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(testRecipient2, testSenderID, "Broadcast from test-ws: hello").
WillReturnResult(sqlmock.NewResult(1, 1))
// Sender log.
mock.ExpectExec(`INSERT INTO activity_logs`).
WithArgs(testSenderID, "Broadcast sent to 1 workspace(s)").
WillReturnResult(sqlmock.NewResult(1, 1))
h.Broadcast(c)
require.Equal(t, http.StatusOK, w.Code)
// 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)
require.Equal(t, float64(1), body["delivered"]) // only testRecipient2 counted
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_NewBroadcastHandler(t *testing.T) {
b := newTestBroadcaster()
h := NewBroadcastHandler(b)
require.NotNil(t, h)
require.Equal(t, b, h.broadcaster)
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)
}
}