|
|
|
@@ -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)
|
|
|
|
|