Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4250e9f9f9 | |||
| e84bf3a4c6 | |||
| 376f78278d |
+12
-10
@@ -145,10 +145,10 @@ jobs:
|
||||
# the diagnostic step with its own continue-on-error: true (line 203).
|
||||
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
|
||||
continue-on-error: false
|
||||
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 10m so
|
||||
# Job-level ceiling. The go test step below runs with a per-step 30m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 30m so
|
||||
# the per-step timeout is the active constraint.
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 35
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
@@ -176,12 +176,14 @@ jobs:
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: always()
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
name: Diagnostic — per-package verbose (300s timeout)
|
||||
run: |
|
||||
set +e
|
||||
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
# 300s allows handlers + pendinguploads packages to complete on cold
|
||||
# runners with -race instrumentation (~60-120s each vs ~14s non-race).
|
||||
go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
handlers_exit=$?
|
||||
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
pu_exit=$?
|
||||
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-handlers.log
|
||||
@@ -194,10 +196,10 @@ jobs:
|
||||
- if: always()
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
# lets the suite complete on cold cache (~5-7m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
# full ./... suite with race detection + coverage. A 30m per-step timeout
|
||||
# lets the suite complete on cold cache (~13-25m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (35m) is a backstop.
|
||||
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: always()
|
||||
name: Per-file coverage report
|
||||
|
||||
@@ -248,6 +248,88 @@ describe("extractResponseText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAgentText", () => {
|
||||
it("extracts text from top-level parts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "Agent said hello" }],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Agent said hello");
|
||||
});
|
||||
|
||||
it("extracts from artifacts[0].parts when top-level parts absent", () => {
|
||||
const task = {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "From artifact block" }] },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("From artifact block");
|
||||
});
|
||||
|
||||
it("extracts from status.message.parts as fallback", () => {
|
||||
const task = {
|
||||
status: {
|
||||
message: { parts: [{ kind: "text", text: "Status text" }] },
|
||||
},
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Status text");
|
||||
});
|
||||
|
||||
it("prefers top-level parts over artifacts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "top-level wins" }],
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "artifact text" }] },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("top-level wins");
|
||||
});
|
||||
|
||||
it("prefers top-level parts over status.message", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "parts wins" }],
|
||||
status: {
|
||||
message: { parts: [{ kind: "text", text: "status text" }] },
|
||||
},
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("parts wins");
|
||||
});
|
||||
|
||||
it("returns string identity when task itself is a string", () => {
|
||||
expect(extractAgentText("plain string task" as unknown as Record<string, unknown>)).toBe(
|
||||
"plain string task",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns fallback when task is an empty object", () => {
|
||||
expect(extractAgentText({})).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("returns fallback when task has no extractable text", () => {
|
||||
expect(
|
||||
extractAgentText({ status: "running", other: "fields" }),
|
||||
).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("tolerates malformed nested shapes without throwing", () => {
|
||||
const task = {
|
||||
parts: null,
|
||||
artifacts: "not an array",
|
||||
status: { message: 42 },
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("joins multiple text parts with newline", () => {
|
||||
const task = {
|
||||
parts: [
|
||||
{ kind: "text", text: "Line one" },
|
||||
{ kind: "text", text: "Line two" },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Line one\nLine two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTextsFromParts", () => {
|
||||
it("extracts text parts with kind=text", () => {
|
||||
const parts = [
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -444,3 +447,178 @@ func TestAdminSchedulesHealth_ResponseFields(t *testing.T) {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── classifyScheduleStatus — additional edge cases ─────────────────────────────────
|
||||
|
||||
func TestClassifyScheduleStatus_ZeroThreshold(t *testing.T) {
|
||||
now := time.Now()
|
||||
lastRun := now.Add(-365 * 24 * time.Hour) // very old
|
||||
result := classifyScheduleStatus(&lastRun, 0, now)
|
||||
if result != "ok" {
|
||||
t.Errorf("classifyScheduleStatus(threshold=0) = %q; want 'ok'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyScheduleStatus_NegativeThreshold(t *testing.T) {
|
||||
now := time.Now()
|
||||
lastRun := now.Add(-24 * time.Hour)
|
||||
result := classifyScheduleStatus(&lastRun, -1*time.Hour, now)
|
||||
if result != "ok" {
|
||||
t.Errorf("classifyScheduleStatus(threshold=-1h) = %q; want 'ok'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyScheduleStatus_ExactlyAtThreshold(t *testing.T) {
|
||||
// Strict >: if now.Sub(lastRun) == threshold, it is NOT stale
|
||||
now := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC)
|
||||
lastRun := time.Date(2026, 5, 18, 10, 0, 0, 0, time.UTC) // exactly 2h ago
|
||||
result := classifyScheduleStatus(&lastRun, 2*time.Hour, now)
|
||||
if result != "ok" {
|
||||
t.Errorf("classifyScheduleStatus(exactly at threshold) = %q; want 'ok'", result)
|
||||
}
|
||||
}
|
||||
|
||||
// ── loadRuntimeProvisionTimeouts (runtime_provision_timeouts.go) ─────────────────
|
||||
|
||||
func writeRuntimeConfigYAML(t *testing.T, tmpDir, templateName, runtime string, timeoutSecs int) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(tmpDir, templateName)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatalf("MkdirAll(%s): %v", dir, err)
|
||||
}
|
||||
yamlContent := "runtime: " + runtime + "\nruntime_config:\n provision_timeout_seconds: " + strconv.Itoa(timeoutSecs) + "\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yamlContent), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_EmptyDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts(empty dir) len = %d; want 0", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_IgnoresNonDirEntries(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "not-a-dir.yaml"), []byte("runtime: hermes\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts(file-only dir) len = %d; want 0", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_SingleTemplate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-hermes", "hermes", 300)
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if v, ok := result["hermes"]; !ok || v != 300 {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → hermes = %d; want 300", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_MultipleTemplatesSameRuntime(t *testing.T) {
|
||||
// Two templates using the same runtime — takes the MAX timeout
|
||||
tmpDir := t.TempDir()
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-hermes-slow", "hermes", 600)
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-hermes-fast", "hermes", 120)
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if v, ok := result["hermes"]; !ok || v != 600 {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → hermes = %d; want 600 (max of 600, 120)", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_MultipleRuntimes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-hermes", "hermes", 300)
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-claude-code", "claude-code", 420)
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-deepagents", "deepagents", 180)
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
want := map[string]int{
|
||||
"hermes": 300,
|
||||
"claude-code": 420,
|
||||
"deepagents": 180,
|
||||
}
|
||||
for runtime, wantSecs := range want {
|
||||
if got, ok := result[runtime]; !ok || got != wantSecs {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → %s = %d; want %d", runtime, got, wantSecs)
|
||||
}
|
||||
}
|
||||
if len(result) != len(want) {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → len = %d; want %d", len(result), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_IgnoresZeroTimeout(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-zero", "zero-runtime", 0)
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if _, ok := result["zero-runtime"]; ok {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → 'zero-runtime' present; want absent (timeout=0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_IgnoresNegativeTimeout(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-negative", "neg-runtime", -60)
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if _, ok := result["neg-runtime"]; ok {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → 'neg-runtime' present; want absent (timeout<0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_IgnoresMissingRuntimeField(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dir := filepath.Join(tmpDir, "tmpl-no-runtime")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yamlContent := "template_name: no-runtime-template\nruntime_config:\n provision_timeout_seconds: 300\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(yamlContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → len = %d; want 0 (runtime field absent)", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_IgnoresMalformedYAML(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dir := filepath.Join(tmpDir, "tmpl-bad-yaml")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badYAML := "runtime: bad\n provision_timeout_seconds: not a number\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(badYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → len = %d; want 0 (malformed YAML)", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_IgnoresMissingConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(tmpDir, "tmpl-no-config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-good", "good-runtime", 300)
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if v, ok := result["good-runtime"]; !ok || v != 300 {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → good-runtime = %d; want 300", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRuntimeProvisionTimeouts_IgnoresEmptyRuntime(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
writeRuntimeConfigYAML(t, tmpDir, "tmpl-empty", "", 300)
|
||||
result := loadRuntimeProvisionTimeouts(tmpDir)
|
||||
if len(result) != 0 {
|
||||
t.Errorf("loadRuntimeProvisionTimeouts → len = %d; want 0 (empty runtime)", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,13 @@ import (
|
||||
|
||||
// BroadcastHandler is constructed once and shared across requests.
|
||||
type BroadcastHandler struct {
|
||||
broadcaster *events.Broadcaster
|
||||
broadcaster events.EventEmitter
|
||||
}
|
||||
|
||||
// NewBroadcastHandler creates a BroadcastHandler.
|
||||
func NewBroadcastHandler(b *events.Broadcaster) *BroadcastHandler {
|
||||
// The emitter is any EventEmitter — the concrete *Broadcaster in production,
|
||||
// or a test double in unit tests.
|
||||
func NewBroadcastHandler(b events.EventEmitter) *BroadcastHandler {
|
||||
return &BroadcastHandler{broadcaster: b}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ func TestBroadcast_OrgScopedRecipients(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
@@ -206,7 +205,7 @@ func TestBroadcast_Disabled(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
senderID := "00000000-0000-0000-0000-000000000003"
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Disabled Agent", false))
|
||||
@@ -237,7 +236,7 @@ func TestBroadcast_EmptyOrg_NoRecipients(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001" // org root, only workspace in org
|
||||
senderID := "00000000-0000-0000-0000-000000000004" // org root, only workspace in org
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
@@ -297,33 +296,12 @@ func TestBroadcast_InvalidWorkspaceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000001"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-000000000001/broadcast", bytes.NewBufferString("{}"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgRootLookupFails verifies that if the recursive CTE for
|
||||
// finding the org root errors, the handler returns 500 instead of proceeding
|
||||
// with an un-scoped query that would broadcast to all orgs.
|
||||
func TestBroadcast_OrgRootLookupFails(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
senderID := "00000000-0000-0000-0000-000000000005"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
@@ -353,16 +331,13 @@ func TestBroadcast_OrgRootLookupFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_OrgScoped_SelfBroadcastExcluded verifies that broadcasting
|
||||
// from a workspace does not send a broadcast_receive to the sender itself
|
||||
// (the sender logs broadcast_sent, not broadcast_receive).
|
||||
func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000001"
|
||||
peerID := "00000000-0000-0000-0000-000000000002"
|
||||
senderID := "00000000-0000-0000-0000-000000000006"
|
||||
peerID := "00000000-0000-0000-0000-000000000007"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
@@ -399,10 +374,145 @@ func TestBroadcast_OrgScoped_SelfBroadcastExcluded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_RecipientActivityLogFails_SkipsAndContinues: if one recipient's
|
||||
// activity_log insert fails, the handler logs the error and continues to the
|
||||
// next recipient rather than aborting the whole broadcast.
|
||||
func TestBroadcast_RecipientActivityLogFails_SkipsAndContinues(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-000000000008"
|
||||
peerA := "00000000-0000-0000-0000-000000000009"
|
||||
peerB := "00000000-0000-0000-0000-00000000000a"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Resilient Agent", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerA).AddRow(peerB))
|
||||
|
||||
// Peer A fails — handler logs and continues
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerA, senderID, sqlmock.AnyArg()).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
// Peer B succeeds
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerB, senderID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Sender log succeeds
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"partial delivery"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
// Only peerB was delivered
|
||||
if int(resp["delivered"].(float64)) != 1 {
|
||||
t.Errorf("expected delivered=1, got %v", resp["delivered"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_SenderActivityLogFails_StillReturns200: if the sender's own
|
||||
// broadcast_sent activity_log insert fails, the handler still returns 200
|
||||
// so the caller doesn't retry a broadcast that already partially delivered.
|
||||
func TestBroadcast_SenderActivityLogFails_StillReturns200(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
senderID := "00000000-0000-0000-0000-00000000000b"
|
||||
peerA := "00000000-0000-0000-0000-00000000000c"
|
||||
|
||||
mock.ExpectQuery(`SELECT name, broadcast_enabled FROM workspaces WHERE id = \$1 AND status != 'removed'`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "broadcast_enabled"}).AddRow("Log-Fail Agent", true))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"root_id"}).AddRow(senderID))
|
||||
|
||||
mock.ExpectQuery(`WITH RECURSIVE org_chain AS`).
|
||||
WithArgs(senderID, senderID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(peerA))
|
||||
|
||||
// Peer log succeeds
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(peerA, senderID, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Sender log FAILS
|
||||
mock.ExpectExec(`INSERT INTO activity_logs`).WithArgs(senderID, sqlmock.AnyArg()).
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: senderID}}
|
||||
body := `{"message":"log fail test"}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+senderID+"/broadcast", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 even on sender log failure, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingMessage(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-00000000000d"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-00000000000d/broadcast", bytes.NewBufferString("{}"))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcast_MissingBody(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewBroadcastHandler(broadcaster)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-00000000000e"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/00000000-0000-0000-0000-00000000000e/broadcast", nil)
|
||||
// no Content-Type and no body
|
||||
|
||||
handler.Broadcast(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// TestBroadcast_Truncate tests that messages are truncated with the Unicode ellipsis
|
||||
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…",
|
||||
// so truncating a 48-char string at max=20 produces 21 characters (20 runes + "…").
|
||||
// character (U+2026) when len(msg) > max. The truncated output is max runes + "…".
|
||||
func TestBroadcast_Truncate(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
@@ -410,14 +520,18 @@ func TestBroadcast_Truncate(t *testing.T) {
|
||||
expect string
|
||||
}{
|
||||
{"short", 120, "short"}, // under max — no truncation
|
||||
// exactly120chars (15) + 105 ones = 120 chars; at max=120 → unchanged
|
||||
// exactly 120 chars → unchanged
|
||||
{"exactly120chars1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", 120, "exactly120chars111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111…"},
|
||||
// "this is a longer mes" = 20 runes; + "…" = 21 chars
|
||||
// 21 runes at max=20 → 20 + "…" = 21 chars
|
||||
{"this is a longer message that needs truncating", 20, "this is a longer mes…"},
|
||||
// at-max boundary: 20 chars at max=20 → no truncation
|
||||
{"exactly twenty chars", 20, "exactly twenty chars"},
|
||||
// over max: 11 chars at max=10 → 10 + "…" = 11
|
||||
{"hello world!", 10, "hello worl…"},
|
||||
// Unicode: 3-rune string at max=3 → unchanged
|
||||
{"日本語", 3, "日本語"},
|
||||
// Empty string → unchanged
|
||||
{"", 120, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
result := broadcastTruncate(tc.msg, tc.max)
|
||||
|
||||
Reference in New Issue
Block a user