Compare commits

..

3 Commits

Author SHA1 Message Date
core-devops 4d4a074538 ci: re-trigger (run 62794 wedged: Canvas Deploy Reminder storm-cancelled 2s zero-output, poisoned needs-graph so all-required Skipped+never posted; Gitea 1.22.6 no REST rerun) [no-op]
CI / Shellcheck (E2E scripts) (pull_request) Blocked by required conditions
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 33s
CI / Detect changes (pull_request) Successful in 1m24s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 20s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m48s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m41s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 34s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m52s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m11s
gate-check-v3 / gate-check (pull_request) Successful in 25s
qa-review / approved (pull_request) Successful in 23s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m44s
security-review / approved (pull_request) Successful in 44s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m6s
sop-tier-check / tier-check (pull_request) Successful in 32s
sop-checklist / all-items-acked (pull_request) Successful in 33s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m11s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m13s
CI / Python Lint & Test (pull_request) Successful in 57s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 17s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 20s
CI / Canvas (Next.js) (pull_request) Successful in 23m39s
CI / Platform (Go) (pull_request) Successful in 24m22s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
audit-force-merge / audit (pull_request) Waiting to run
CI / all-required (pull_request) Has been cancelled
2026-05-16 07:14:27 +00:00
core-devops ed75d60ca3 ci: re-trigger wedged CI run (detect-changes never dispatched all-required; Gitea 1.22.6 has no REST rerun) [no-op]
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 18s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 34s
CI / Detect changes (pull_request) Successful in 1m8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m16s
E2E Chat / detect-changes (pull_request) Successful in 1m18s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
gate-check-v3 / gate-check (pull_request) Successful in 33s
qa-review / approved (pull_request) Successful in 34s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m22s
security-review / approved (pull_request) Successful in 49s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 2m14s
sop-tier-check / tier-check (pull_request) Successful in 1m20s
sop-checklist / all-items-acked (pull_request) Successful in 1m23s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m53s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m33s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m31s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 20s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 43s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 30s
CI / Python Lint & Test (pull_request) Successful in 50s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 35s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Failing after 14m30s
E2E Chat / E2E Chat (pull_request) Failing after 13m33s
CI / Canvas (Next.js) (pull_request) Successful in 22m40s
CI / Platform (Go) (pull_request) Successful in 23m53s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 6s
2026-05-16 06:37:37 +00:00
core-devops 95048afa27 fix(ci): pin e2e-chat setup-node to mirrored v6.4.0 SHA
CI / Detect changes (pull_request) Waiting to run
CI / Canvas (Next.js) (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Waiting to run
E2E Chat / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 40s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 31s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 30s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m6s
qa-review / approved (pull_request) Successful in 31s
security-review / approved (pull_request) Successful in 30s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m35s
sop-checklist / all-items-acked (pull_request) Has started running
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m58s
gate-check-v3 / gate-check (pull_request) Successful in 43s
sop-tier-check / tier-check (pull_request) Successful in 33s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m41s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 14s
CI / Platform (Go) (pull_request) Successful in 22m17s
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
E2E API Smoke Test / E2E API Smoke Test (pull_request) Has been cancelled
E2E Chat / E2E Chat (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
The E2E Chat job has a 100% failure rate since inception (33 failures,
0 successes across all PRs + staging HEAD). Root cause: the workflow
pinned actions/setup-node@60edb5dd... (v4), whose SHA was never
replicated into the self-hosted Gitea actions mirror
(git.moleculesai.app/actions/setup-node -> 404 reference not found).
Every run dies at action resolution, ~12s in, before any test code.

Sibling Playwright workflow e2e-staging-canvas.yml already uses the
mirrored v6.4.0 SHA 48b55a011bda... (resolves 200). Align e2e-chat.yml
to the same pin so the job can actually execute.

This is not a #1277 regression and not a code defect — E2E Chat has
never run a single test. The check is non-blocking (continue-on-error,
not yet in branch protection per the bp-required pending-#1142 note),
so no merges were gated by this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:34:56 -07:00
2 changed files with 1 additions and 278 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ jobs:
cache-dependency-path: workspace-server/go.sum
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d6f5 # v4
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
@@ -1,277 +0,0 @@
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)
}