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