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
@@ -0,0 +1,410 @@
package handlers
import (
"bytes"
"context"
"database/sql"
"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"
)
// 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(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
t.Fatalf("failed to create sqlmock: %v", err)
}
prevDB := db.DB
db.DB = mockDB
t.Cleanup(func() { db.DB = prevDB; mockDB.Close() })
return mock
}
// broadcastTestUUID is a properly formatted test UUID.
const broadcastTestUUID = "bbbbbbbb-0001-0001-0001-000000000001"
// 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)
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
}
// ─── Pure function ─────────────────────────────────────────────────────────────
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…"},
}
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)
}
})
}
}
// ─── 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("want 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestBroadcast_MissingMessage(t *testing.T) {
mock := setupBroadcastDB(t)
c, w := buildBroadcastCtx(broadcastTestUUID, `{}`)
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)
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
handler.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_WorkspaceLookupQueryError(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).
WillReturnError(sql.ErrConnDone)
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
handler.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("want 403, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
}
}
// ─── DB error paths ────────────────────────────────────────────────────────────
func TestBroadcast_RecipientQueryError(t *testing.T) {
mock := setupBroadcastDB(t)
c, w := buildBroadcastCtx(broadcastTestUUID, `{"message":"hello"}`)
// 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))
// 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("want 500, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
}
}
func TestBroadcast_RecipientRowsError(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("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))
// 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))
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
}
}
func TestBroadcast_Success_NoRecipients(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("solo-workspace", true))
// 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))
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
}
}
func TestBroadcast_Success_MultipleRecipients(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))
// 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))
handler := NewBroadcastHandler(events.NewBroadcaster(ws.NewHub(nil)))
handler.Broadcast(c)
if w.Code != http.StatusOK {
t.Errorf("want 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
}
}
// ─── Recipient insert failure (logged, continues) ─────────────────────────────
func TestBroadcast_RecipientInsertError_ContinuesAndSucceeds(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))
// 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 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))
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("want 200 despite insert error, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
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)
}
}