diff --git a/workspace-server/internal/handlers/workspace_broadcast_test.go b/workspace-server/internal/handlers/workspace_broadcast_test.go new file mode 100644 index 000000000..789df7cc3 --- /dev/null +++ b/workspace-server/internal/handlers/workspace_broadcast_test.go @@ -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) + } +}