Files
gochat/backend/internal/ws/event_publisher_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
2026-07-07 14:44:12 +08:00

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