Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
712 lines
22 KiB
Go
712 lines
22 KiB
Go
package ws
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// === EventPublisher Tests ===
|
|
// Uses mockHandler (from broadcast_test.go) as the MessageHandler implementation,
|
|
// plus SSERegistry for SSE delivery verification.
|
|
|
|
func TestEventPublisher_LocalDelivery(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
// Subscribe SSE client to account 1
|
|
ch := sse.Subscribe("sse1", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{"id": 1, "content": "hello"}
|
|
publisher.PublishEvent(1, EventMessageCreated, payload)
|
|
|
|
// Verify WebSocket Hub received the event
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data, "Hub should have received event for account 1")
|
|
|
|
var wsMsg WSMessage
|
|
err := json.Unmarshal(data, &wsMsg)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, EventMessageCreated, wsMsg.Event)
|
|
assert.Equal(t, uint(1), wsMsg.AccountID)
|
|
|
|
// Verify SSE channel received the event
|
|
select {
|
|
case event := <-ch.Events:
|
|
assert.Equal(t, EventMessageCreated, event.Type)
|
|
default:
|
|
t.Fatal("SSE channel should have received event")
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_AccountRouting(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
// Subscribe SSE clients to different accounts
|
|
ch1 := sse.Subscribe("sse1", 1, 100)
|
|
ch2 := sse.Subscribe("sse2", 2, 200)
|
|
require.NotNil(t, ch1)
|
|
require.NotNil(t, ch2)
|
|
|
|
payload := map[string]interface{}{"id": 1}
|
|
publisher.PublishEvent(1, EventConversationCreated, payload)
|
|
|
|
// Account 1 SSE client should receive
|
|
select {
|
|
case event := <-ch1.Events:
|
|
assert.Equal(t, EventConversationCreated, event.Type)
|
|
default:
|
|
t.Fatal("account 1 SSE client should receive event")
|
|
}
|
|
|
|
// Account 2 SSE client should NOT receive
|
|
assertNoSSEEvent(t, ch2)
|
|
}
|
|
|
|
func TestEventPublisher_ConversationEvent(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
// Subscribe SSE clients
|
|
chBroad := sse.Subscribe("sse_broad", 1, 100) // no conv filter
|
|
chFiltered := sse.Subscribe("sse_filtered", 1, 200)
|
|
sse.SubscribeConversation("sse_filtered", 42)
|
|
|
|
require.NotNil(t, chBroad)
|
|
require.NotNil(t, chFiltered)
|
|
|
|
payload := map[string]interface{}{"message": "typing..."}
|
|
publisher.PublishConversationEvent(1, 42, EventConversationTypingOn, payload)
|
|
|
|
// Broad SSE client should receive (no conversation filter)
|
|
assertSSEEventReceived(t, chBroad, SSEEvent{Type: EventConversationTypingOn})
|
|
|
|
// Filtered SSE client should receive (subscribed to conv 42)
|
|
assertSSEEventReceived(t, chFiltered, SSEEvent{Type: EventConversationTypingOn})
|
|
}
|
|
|
|
func TestEventPublisher_ConversationEvent_FilteredOut(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
// Subscribe SSE client to conversation 99 only
|
|
ch := sse.Subscribe("sse1", 1, 100)
|
|
sse.SubscribeConversation("sse1", 99)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{"message": "typing..."}
|
|
publisher.PublishConversationEvent(1, 42, EventConversationTypingOn, payload)
|
|
|
|
// Client subscribed to conv 99 should NOT receive conv 42 event
|
|
assertNoSSEEvent(t, ch)
|
|
}
|
|
|
|
func TestEventPublisher_AllEventTypes(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
ch := sse.Subscribe("sse1", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
eventTypes := []string{
|
|
EventMessageCreated,
|
|
EventMessageUpdated,
|
|
EventMessageDeleted,
|
|
EventConversationCreated,
|
|
EventConversationUpdated,
|
|
EventConversationStatusChanged,
|
|
EventAssigneeChanged,
|
|
EventTeamChanged,
|
|
EventContactCreated,
|
|
EventContactUpdated,
|
|
EventContactDeleted,
|
|
EventInboxCreated,
|
|
EventInboxUpdated,
|
|
EventInboxDeleted,
|
|
EventNotificationCreated,
|
|
EventNotificationUpdated,
|
|
EventNotificationDeleted,
|
|
EventAccountCacheInvalidated,
|
|
}
|
|
|
|
for _, eventType := range eventTypes {
|
|
payload := map[string]interface{}{"test": eventType}
|
|
publisher.PublishEvent(1, eventType, payload)
|
|
|
|
// Verify SSE delivery
|
|
select {
|
|
case event := <-ch.Events:
|
|
assert.Equal(t, eventType, event.Type)
|
|
default:
|
|
t.Fatalf("SSE channel should receive event type: %s", eventType)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_NoHub(t *testing.T) {
|
|
sse := NewSSERegistry()
|
|
// Create publisher with no hub — SSE should still work
|
|
publisher := NewEventPublisherLocal(nil, sse)
|
|
|
|
ch := sse.Subscribe("sse1", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{"id": 1}
|
|
publisher.PublishEvent(1, EventMessageCreated, payload)
|
|
|
|
// SSE should still receive the event
|
|
select {
|
|
case event := <-ch.Events:
|
|
assert.Equal(t, EventMessageCreated, event.Type)
|
|
default:
|
|
t.Fatal("SSE channel should receive event even without hub")
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_NoSSE(t *testing.T) {
|
|
hub := newMockHandler()
|
|
// Create publisher with no SSE — hub should still work
|
|
publisher := NewEventPublisherLocal(hub, nil)
|
|
|
|
payload := map[string]interface{}{"id": 1}
|
|
publisher.PublishEvent(1, EventMessageCreated, payload)
|
|
|
|
// Hub should still receive the event
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data, "Hub should receive event even without SSE")
|
|
}
|
|
|
|
func TestEventPublisher_NilTargets(t *testing.T) {
|
|
// Both hub and SSE are nil — should not panic
|
|
publisher := NewEventPublisherLocal(nil, nil)
|
|
|
|
payload := map[string]interface{}{"id": 1}
|
|
publisher.PublishEvent(1, EventMessageCreated, payload)
|
|
// No crash = success
|
|
}
|
|
|
|
func TestEventPublisher_WSMessageFormat(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
payload := map[string]interface{}{
|
|
"id": 42,
|
|
"content": "test message",
|
|
"status": "open",
|
|
}
|
|
publisher.PublishEvent(1, EventConversationCreated, payload)
|
|
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data)
|
|
|
|
var wsMsg WSMessage
|
|
err := json.Unmarshal(data, &wsMsg)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, EventConversationCreated, wsMsg.Event)
|
|
assert.Equal(t, uint(1), wsMsg.AccountID)
|
|
// Data should contain the payload fields
|
|
dataMap, ok := wsMsg.Data.(map[string]interface{})
|
|
require.True(t, ok, "Data should be a map")
|
|
assert.Equal(t, float64(42), dataMap["id"])
|
|
assert.Equal(t, "test message", dataMap["content"])
|
|
}
|
|
|
|
func TestEventPublisher_MessageCreatedChatwootPayloadShape(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
ch := sse.Subscribe("message-created-fixture", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{
|
|
"id": 501,
|
|
"conversation_id": 42,
|
|
"display_id": 42,
|
|
"account_id": 1,
|
|
"inbox_id": 7,
|
|
"message_type": "outgoing",
|
|
"content_type": "text",
|
|
"content": "hello from agent",
|
|
"content_attributes": map[string]interface{}{"submitted_email": "contact@example.com"},
|
|
"sender": map[string]interface{}{
|
|
"id": 100,
|
|
"name": "Agent Ada",
|
|
"type": "user",
|
|
"email": "ada@example.com",
|
|
},
|
|
"attachments": []interface{}{
|
|
map[string]interface{}{
|
|
"id": 900,
|
|
"message_id": 501,
|
|
"file_type": "image",
|
|
"data_url": "https://cdn.example.test/image.png",
|
|
"download_url": "https://cdn.example.test/image.png",
|
|
"conversation_id": 42,
|
|
},
|
|
},
|
|
}
|
|
publisher.PublishEvent(1, EventMessageCreated, payload)
|
|
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data)
|
|
var wsMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(data, &wsMsg))
|
|
require.Equal(t, EventMessageCreated, wsMsg.Event)
|
|
require.Equal(t, uint(1), wsMsg.AccountID)
|
|
|
|
dataMap, ok := wsMsg.Data.(map[string]interface{})
|
|
require.True(t, ok)
|
|
require.Equal(t, float64(501), dataMap["id"])
|
|
require.Equal(t, float64(42), dataMap["conversation_id"])
|
|
require.Equal(t, "outgoing", dataMap["message_type"])
|
|
require.Equal(t, "text", dataMap["content_type"])
|
|
require.Equal(t, "hello from agent", dataMap["content"])
|
|
require.Contains(t, dataMap, "content_attributes")
|
|
require.Contains(t, dataMap, "sender")
|
|
require.Contains(t, dataMap, "attachments")
|
|
require.Equal(t, "Agent Ada", dataMap["sender"].(map[string]interface{})["name"])
|
|
attachments := dataMap["attachments"].([]interface{})
|
|
require.Len(t, attachments, 1)
|
|
require.Equal(t, float64(42), attachments[0].(map[string]interface{})["conversation_id"])
|
|
|
|
select {
|
|
case event := <-ch.Events:
|
|
require.Equal(t, EventMessageCreated, event.Type)
|
|
fixture := event.Payload.(map[string]interface{})
|
|
require.Equal(t, "hello from agent", fixture["content"])
|
|
require.Contains(t, fixture, "sender")
|
|
require.Contains(t, fixture, "attachments")
|
|
default:
|
|
t.Fatal("SSE channel should receive Chatwoot message.created payload")
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_MessageUpdatedChatwootPayloadShape(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
ch := sse.Subscribe("message-updated-fixture", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{
|
|
"id": 502,
|
|
"conversation_id": 42,
|
|
"account_id": 1,
|
|
"inbox_id": 7,
|
|
"message_type": "outgoing",
|
|
"content_type": "text",
|
|
"content": "provider rejected",
|
|
"status": "failed",
|
|
"content_attributes": map[string]interface{}{
|
|
"external_error": "provider rejected message",
|
|
},
|
|
"sender": map[string]interface{}{
|
|
"id": 100,
|
|
"name": "Agent Ada",
|
|
"type": "user",
|
|
"email": "ada@example.com",
|
|
},
|
|
"attachments": []interface{}{},
|
|
}
|
|
publisher.PublishEvent(1, EventMessageUpdated, payload)
|
|
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data)
|
|
var wsMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(data, &wsMsg))
|
|
require.Equal(t, EventMessageUpdated, wsMsg.Event)
|
|
require.Equal(t, uint(1), wsMsg.AccountID)
|
|
|
|
dataMap, ok := wsMsg.Data.(map[string]interface{})
|
|
require.True(t, ok)
|
|
require.Equal(t, float64(502), dataMap["id"])
|
|
require.Equal(t, float64(42), dataMap["conversation_id"])
|
|
require.Equal(t, "failed", dataMap["status"])
|
|
require.Equal(t, "provider rejected", dataMap["content"])
|
|
contentAttrs := dataMap["content_attributes"].(map[string]interface{})
|
|
require.Equal(t, "provider rejected message", contentAttrs["external_error"])
|
|
require.Contains(t, dataMap, "sender")
|
|
require.Contains(t, dataMap, "attachments")
|
|
|
|
select {
|
|
case event := <-ch.Events:
|
|
require.Equal(t, EventMessageUpdated, event.Type)
|
|
fixture := event.Payload.(map[string]interface{})
|
|
require.Equal(t, 502, fixture["id"])
|
|
require.Equal(t, "failed", fixture["status"])
|
|
case <-time.After(time.Second):
|
|
t.Fatal("SSE channel should receive message.updated fixture")
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_ConversationMutationChatwootPayloadShape(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
ch := sse.Subscribe("conversation-mutation-fixture", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{
|
|
"id": 42,
|
|
"account_id": 1,
|
|
"inbox_id": 7,
|
|
"status": "resolved",
|
|
"priority": "urgent",
|
|
"labels": []interface{}{"support", "urgent"},
|
|
"assignee_id": 100,
|
|
"team_id": 55,
|
|
"changed_attributes": map[string]interface{}{
|
|
"status": map[string]interface{}{"from": "open", "to": "resolved"},
|
|
"priority": map[string]interface{}{"from": "medium", "to": "urgent"},
|
|
},
|
|
"meta": map[string]interface{}{
|
|
"assignee": map[string]interface{}{"id": 100, "name": "Agent Ada"},
|
|
"team": map[string]interface{}{"id": 55, "name": "Support"},
|
|
},
|
|
}
|
|
publisher.PublishEvent(1, EventConversationUpdated, payload)
|
|
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data)
|
|
var wsMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(data, &wsMsg))
|
|
require.Equal(t, EventConversationUpdated, wsMsg.Event)
|
|
require.Equal(t, uint(1), wsMsg.AccountID)
|
|
|
|
dataMap, ok := wsMsg.Data.(map[string]interface{})
|
|
require.True(t, ok)
|
|
require.Equal(t, float64(42), dataMap["id"])
|
|
require.Equal(t, "resolved", dataMap["status"])
|
|
require.Equal(t, "urgent", dataMap["priority"])
|
|
require.Contains(t, dataMap, "labels")
|
|
require.Contains(t, dataMap, "meta")
|
|
changes := dataMap["changed_attributes"].(map[string]interface{})
|
|
require.Equal(t, "resolved", changes["status"].(map[string]interface{})["to"])
|
|
require.Equal(t, "urgent", changes["priority"].(map[string]interface{})["to"])
|
|
|
|
select {
|
|
case event := <-ch.Events:
|
|
require.Equal(t, EventConversationUpdated, event.Type)
|
|
fixture := event.Payload.(map[string]interface{})
|
|
require.Equal(t, 42, fixture["id"])
|
|
require.Equal(t, "resolved", fixture["status"])
|
|
case <-time.After(time.Second):
|
|
t.Fatal("SSE channel should receive conversation.updated fixture")
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_TypingChatwootPayloadShape(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
ch := sse.Subscribe("typing-fixture", 1, 100)
|
|
sse.SubscribeConversation("typing-fixture", 42)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{
|
|
"conversation_id": 42,
|
|
"account_id": 1,
|
|
"user_id": 100,
|
|
"typing_status": "on",
|
|
"is_private": true,
|
|
}
|
|
publisher.PublishConversationEvent(1, 42, EventConversationTypingOn, payload)
|
|
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data)
|
|
var wsMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(data, &wsMsg))
|
|
require.Equal(t, EventConversationTypingOn, wsMsg.Event)
|
|
dataMap := wsMsg.Data.(map[string]interface{})
|
|
require.Equal(t, float64(42), dataMap["conversation_id"])
|
|
require.Equal(t, float64(100), dataMap["user_id"])
|
|
require.Equal(t, "on", dataMap["typing_status"])
|
|
require.Equal(t, true, dataMap["is_private"])
|
|
|
|
select {
|
|
case event := <-ch.Events:
|
|
require.Equal(t, EventConversationTypingOn, event.Type)
|
|
fixture := event.Payload.(map[string]interface{})
|
|
require.Equal(t, 42, fixture["conversation_id"])
|
|
require.Equal(t, "on", fixture["typing_status"])
|
|
case <-time.After(time.Second):
|
|
t.Fatal("SSE channel should receive conversation.typing_on fixture")
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_PresenceUpdateChatwootPayloadShape(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
ch := sse.Subscribe("presence-fixture", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{
|
|
"account_id": 1,
|
|
"users": map[uint]string{100: "busy"},
|
|
"contacts": map[uint]string{501: "online"},
|
|
}
|
|
publisher.PublishEvent(1, EventPresenceUpdate, payload)
|
|
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data)
|
|
var wsMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(data, &wsMsg))
|
|
require.Equal(t, EventPresenceUpdate, wsMsg.Event)
|
|
require.Equal(t, uint(1), wsMsg.AccountID)
|
|
|
|
dataMap := wsMsg.Data.(map[string]interface{})
|
|
require.Equal(t, float64(1), dataMap["account_id"])
|
|
users := dataMap["users"].(map[string]interface{})
|
|
require.Equal(t, "busy", users["100"])
|
|
contacts := dataMap["contacts"].(map[string]interface{})
|
|
require.Equal(t, "online", contacts["501"])
|
|
|
|
select {
|
|
case event := <-ch.Events:
|
|
require.Equal(t, EventPresenceUpdate, event.Type)
|
|
fixture := event.Payload.(map[string]interface{})
|
|
require.Equal(t, map[uint]string{100: "busy"}, fixture["users"])
|
|
case <-time.After(time.Second):
|
|
t.Fatal("SSE channel should receive presence.update fixture")
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_NotificationChatwootPayloadShape(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
ch := sse.Subscribe("notification-fixture", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
notification := map[string]interface{}{
|
|
"id": 701,
|
|
"account_id": 1,
|
|
"user_id": 100,
|
|
"notification_type": "conversation_created",
|
|
"primary_actor_type": "Conversation",
|
|
"primary_actor_id": 42,
|
|
"secondary_actor_type": "Message",
|
|
"secondary_actor_id": 501,
|
|
"read_at": nil,
|
|
"snoozed_until": nil,
|
|
"created_at": "2026-06-12T10:00:00Z",
|
|
"last_activity_at": "2026-06-12T10:01:00Z",
|
|
"meta": map[string]interface{}{
|
|
"sender": map[string]interface{}{"id": 301, "name": "Visitor"},
|
|
},
|
|
"additional_attributes": map[string]interface{}{
|
|
"browser_title": "New customer message",
|
|
},
|
|
"primary_actor": map[string]interface{}{
|
|
"id": 42,
|
|
"display_id": 42,
|
|
"status": "open",
|
|
},
|
|
"secondary_actor": map[string]interface{}{
|
|
"id": 501,
|
|
"conversation_id": 42,
|
|
"content": "hello",
|
|
},
|
|
"user": map[string]interface{}{
|
|
"id": 100,
|
|
"name": "Agent Ada",
|
|
},
|
|
}
|
|
|
|
for _, eventType := range []string{EventNotificationCreated, EventNotificationUpdated, EventNotificationDeleted} {
|
|
t.Run(eventType, func(t *testing.T) {
|
|
payload := map[string]interface{}{
|
|
"notification": notification,
|
|
"unread_count": 3,
|
|
"count": 7,
|
|
}
|
|
publisher.PublishEvent(1, eventType, payload)
|
|
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data)
|
|
var wsMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(data, &wsMsg))
|
|
require.Equal(t, eventType, wsMsg.Event)
|
|
require.Equal(t, uint(1), wsMsg.AccountID)
|
|
|
|
dataMap, ok := wsMsg.Data.(map[string]interface{})
|
|
require.True(t, ok)
|
|
require.Equal(t, float64(3), dataMap["unread_count"])
|
|
require.Equal(t, float64(7), dataMap["count"])
|
|
|
|
notificationMap := dataMap["notification"].(map[string]interface{})
|
|
require.Equal(t, float64(701), notificationMap["id"])
|
|
require.Equal(t, float64(1), notificationMap["account_id"])
|
|
require.Equal(t, "conversation_created", notificationMap["notification_type"])
|
|
require.Equal(t, "Conversation", notificationMap["primary_actor_type"])
|
|
require.Equal(t, float64(42), notificationMap["primary_actor_id"])
|
|
require.Equal(t, "Message", notificationMap["secondary_actor_type"])
|
|
require.Equal(t, float64(501), notificationMap["secondary_actor_id"])
|
|
require.Contains(t, notificationMap, "read_at")
|
|
require.Contains(t, notificationMap, "snoozed_until")
|
|
require.Contains(t, notificationMap, "meta")
|
|
require.Contains(t, notificationMap, "additional_attributes")
|
|
require.Contains(t, notificationMap, "primary_actor")
|
|
require.Contains(t, notificationMap, "secondary_actor")
|
|
require.Contains(t, notificationMap, "user")
|
|
|
|
select {
|
|
case event := <-ch.Events:
|
|
require.Equal(t, eventType, event.Type)
|
|
fixture := event.Payload.(map[string]interface{})
|
|
require.Equal(t, 3, fixture["unread_count"])
|
|
require.Equal(t, 7, fixture["count"])
|
|
require.Equal(t, notification, fixture["notification"])
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("SSE channel should receive %s fixture", eventType)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEventPublisher_AccountCacheInvalidatedChatwootPayloadShape(t *testing.T) {
|
|
hub := newMockHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
ch := sse.Subscribe("account-cache-fixture", 1, 100)
|
|
require.NotNil(t, ch)
|
|
|
|
payload := map[string]interface{}{
|
|
"cache_keys": map[string]interface{}{
|
|
"label": "labels-v2",
|
|
"inbox": "inboxes-v2",
|
|
"team": "teams-v2",
|
|
},
|
|
}
|
|
publisher.PublishEvent(1, EventAccountCacheInvalidated, payload)
|
|
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data)
|
|
var wsMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(data, &wsMsg))
|
|
require.Equal(t, EventAccountCacheInvalidated, wsMsg.Event)
|
|
require.Equal(t, uint(1), wsMsg.AccountID)
|
|
|
|
dataMap := wsMsg.Data.(map[string]interface{})
|
|
cacheKeys := dataMap["cache_keys"].(map[string]interface{})
|
|
require.Equal(t, "labels-v2", cacheKeys["label"])
|
|
require.Equal(t, "inboxes-v2", cacheKeys["inbox"])
|
|
require.Equal(t, "teams-v2", cacheKeys["team"])
|
|
|
|
select {
|
|
case event := <-ch.Events:
|
|
require.Equal(t, EventAccountCacheInvalidated, event.Type)
|
|
fixture := event.Payload.(map[string]interface{})
|
|
require.Equal(t, payload["cache_keys"], fixture["cache_keys"])
|
|
case <-time.After(time.Second):
|
|
t.Fatal("SSE channel should receive account.cache_invalidated fixture")
|
|
}
|
|
}
|
|
|
|
// === Mock MessageHandler reuse ===
|
|
// The mockHandler type is defined in broadcast_test.go in the same package.
|
|
// We reuse it here for EventPublisher tests. If needed, here's a duplicate
|
|
// for reference (Go test files in the same package share types):
|
|
//
|
|
// Note: mockHandler is already defined in broadcast_test.go and accessible
|
|
// from this test file since they're in the same Go package (ws).
|
|
|
|
// Additional mock for tracking room-based sends
|
|
type mockRoomHandler struct {
|
|
mu sync.Mutex
|
|
accounts map[uint][]byte
|
|
rooms map[string][]byte
|
|
}
|
|
|
|
func newMockRoomHandler() *mockRoomHandler {
|
|
return &mockRoomHandler{
|
|
accounts: make(map[uint][]byte),
|
|
rooms: make(map[string][]byte),
|
|
}
|
|
}
|
|
|
|
func (m *mockRoomHandler) SendToAccount(accountID uint, data []byte) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.accounts[accountID] = data
|
|
}
|
|
|
|
func (m *mockRoomHandler) SendToRoom(room string, data []byte) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.rooms[room] = data
|
|
}
|
|
|
|
func (m *mockRoomHandler) getAccountData(accountID uint) []byte {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.accounts[accountID]
|
|
}
|
|
|
|
func (m *mockRoomHandler) getRoomData(room string) []byte {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.rooms[room]
|
|
}
|
|
|
|
func TestEventPublisher_ConversationEvent_RoomDelivery(t *testing.T) {
|
|
hub := newMockRoomHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
payload := map[string]interface{}{"message": "typing..."}
|
|
publisher.PublishConversationEvent(1, 42, EventConversationTypingOn, payload)
|
|
|
|
// Verify account room received
|
|
data := hub.getAccountData(1)
|
|
require.NotNil(t, data, "Hub account room should receive event")
|
|
|
|
// Verify conversation room received
|
|
convRoom := conversationRoomNameHelper(1, 42)
|
|
roomData := hub.getRoomData(convRoom)
|
|
require.NotNil(t, roomData, "Hub conversation room should receive event")
|
|
}
|
|
|
|
func TestEventPublisher_WidgetEvent_PubsubTokenRoomDelivery(t *testing.T) {
|
|
hub := newMockRoomHandler()
|
|
sse := NewSSERegistry()
|
|
publisher := NewEventPublisherLocal(hub, sse)
|
|
|
|
payload := map[string]interface{}{"id": 7, "content": "hello widget"}
|
|
publisher.PublishWidgetEvent(1, "pubsub-123", EventMessageCreated, payload)
|
|
|
|
accountData := hub.getAccountData(1)
|
|
require.NotNil(t, accountData, "Hub account room should receive widget event")
|
|
var accountMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(accountData, &accountMsg))
|
|
assert.Equal(t, EventMessageCreated, accountMsg.Event)
|
|
|
|
roomData := hub.getRoomData("pubsub_token_pubsub-123")
|
|
require.NotNil(t, roomData, "Hub pubsub_token room should receive widget event")
|
|
var roomMsg WSMessage
|
|
require.NoError(t, json.Unmarshal(roomData, &roomMsg))
|
|
assert.Equal(t, EventMessageCreated, roomMsg.Event)
|
|
assert.Equal(t, uint(1), roomMsg.AccountID)
|
|
}
|