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..9a5ae9e6e --- /dev/null +++ b/workspace-server/internal/handlers/workspace_broadcast_test.go @@ -0,0 +1,277 @@ +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 ( + "context" + "encoding/json" + "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 ───────────────────────────────────────────────────────── + +func TestBroadcastTruncate_LenBelowMax_ReturnsFullString(t *testing.T) { + result := broadcastTruncate("hello", 10) + require.Equal(t, "hello", result) +} + +func TestBroadcastTruncate_LenEqualMax_ReturnsFullString(t *testing.T) { + result := broadcastTruncate("hello", 5) + require.Equal(t, "hello", result) +} + +func TestBroadcastTruncate_LenAboveMax_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) +} + +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) +} + +// ─── Broadcast handler ──────────────────────────────────────────────────────── + +// 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) { + t.Helper() + mockDB, mock, err := sqlmock.New() + require.NoError(t, err) + prevDB := 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 +} + +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"}} + + h := NewBroadcastHandler(newTestBroadcaster()) + h.Broadcast(c) + + require.Equal(t, http.StatusBadRequest, w.Code) + var body map[string]string + json.Unmarshal(w.Body.Bytes(), &body) + require.Contains(t, body["error"], "invalid workspace ID") +} + +func TestBroadcast_MissingMessage_Returns400(t *testing.T) { + h, _, w, c := setupBroadcastCtx(t, `{}`) + + // ShouldBindJSON fails first — no DB query expected. + h.Broadcast(c) + + require.Equal(t, http.StatusBadRequest, w.Code) + var body map[string]string + json.Unmarshal(w.Body.Bytes(), &body) + require.Equal(t, "message is required", body["error"]) +} + +func TestBroadcast_WorkspaceNotFound_Returns404(t *testing.T) { + 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"})) // empty + + 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"]) +} + +func TestBroadcast_BroadcastDisabled_Returns403(t *testing.T) { + 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", false)) + + h.Broadcast(c) + + require.Equal(t, http.StatusForbidden, w.Code) + var body map[string]string + json.Unmarshal(w.Body.Bytes(), &body) + require.Equal(t, "broadcast_disabled", body["error"]) +} + +func TestBroadcast_RecipientQueryError_Returns500(t *testing.T) { + 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)) + mock.ExpectQuery(`SELECT id FROM workspaces WHERE status != 'removed' AND id != \$1`). + WithArgs(testSenderID). + WillReturnError(context.DeadlineExceeded) + + h.Broadcast(c) + + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestBroadcast_Success_Returns200AndDeliveredCount(t *testing.T) { + h, mock, w, c := setupBroadcastCtx(t, `{"message":"hello world"}`) + + 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)) + // 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)) + + h.Broadcast(c) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + require.Equal(t, "sent", body["status"]) + require.Equal(t, float64(2), body["delivered"]) +} + +func TestBroadcast_NoRecipients_ReturnsZeroDelivered(t *testing.T) { + 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("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)) + + h.Broadcast(c) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + require.Equal(t, "sent", body["status"]) + require.Equal(t, float64(0), body["delivered"]) +} + +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"}`) + + 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)) + 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) + + 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) + var body map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &body) + require.Equal(t, float64(1), body["delivered"]) // only testRecipient2 counted +} + +func TestBroadcast_NewBroadcastHandler(t *testing.T) { + b := newTestBroadcaster() + h := NewBroadcastHandler(b) + require.NotNil(t, h) + require.Equal(t, b, h.broadcaster) +}