import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import ActionCableConnector from '../actionCable'; import DashboardAudioNotificationHelper from '../AudioAlerts/DashboardAudioNotificationHelper'; import conversationActions from '../../store/modules/conversations/actions'; import { mutations as conversationMutations } from '../../store/modules/conversations'; import { useCallsStore } from '../../stores/calls'; vi.mock('../AudioAlerts/DashboardAudioNotificationHelper', () => ({ default: { onNewMessage: vi.fn(), }, })); vi.mock('shared/helpers/mitt', () => ({ emitter: { emit: vi.fn(), }, })); vi.mock('dashboard/composables/useImpersonation', () => ({ useImpersonation: () => ({ isImpersonating: { value: false }, }), })); global.chatwootConfig = { websocketURL: 'wss://test.chatwoot.com', }; describe('ActionCableConnector - Copilot Tests', () => { let store; let actionCable; let mockDispatch; beforeEach(() => { vi.clearAllMocks(); mockDispatch = vi.fn(); store = { $store: { dispatch: mockDispatch, getters: { getCurrentAccountId: 1, 'accounts/isFeatureEnabledonAccount': vi.fn(() => true), }, }, }; actionCable = ActionCableConnector.init(store.$store, 'test-token'); }); afterEach(() => { vi.useRealTimers(); }); const useRealConversationDispatch = state => { const commit = (type, payload) => conversationMutations[type](state, payload); mockDispatch.mockImplementation((action, payload) => { if (action === 'addMessage') { return conversationActions.addMessage( { commit, state, rootGetters: { getCurrentUserID: 1 } }, payload ); } if (action === 'updateConversationLastActivity') { return conversationActions.updateConversationLastActivity( { commit }, payload ); } return undefined; }); }; describe('copilot event handlers', () => { it('should register the copilot.message.created event handler', () => { expect(Object.keys(actionCable.events)).toContain( 'copilot.message.created' ); expect(actionCable.events['copilot.message.created']).toBe( actionCable.onCopilotMessageCreated ); }); it('should handle the copilot.message.created event through the ActionCable system', () => { const copilotData = { id: 2, content: 'This is a copilot message from ActionCable', conversation_id: 456, created_at: '2025-05-27T15:58:04-06:00', account_id: 1, }; actionCable.onReceived({ event: 'copilot.message.created', data: copilotData, }); expect(mockDispatch).toHaveBeenCalledWith( 'copilotMessages/upsert', copilotData ); }); }); it('handles native assignment events and refreshes authoritative conversation data', async () => { expect(actionCable.events['conversation.assigned']).toBe( actionCable.onConversationAssigned ); expect(actionCable.events['conversation.unassigned']).toBe( actionCable.onConversationAssigned ); actionCable.onReceived({ event: 'conversation.assigned', data: { account_id: 1, id: 42 }, }); expect(mockDispatch).toHaveBeenCalledWith('getConversation', 42); }); it('hydrates a message whose realtime event arrives before conversation creation', async () => { const message = { id: 42, account_id: 1, conversation_id: 7, conversation: { last_activity_at: 123 }, }; mockDispatch.mockImplementation(action => { if (action === 'addMessage') return Promise.resolve(false); if (action === 'getConversation') { return Promise.resolve({ id: 7, messages: [message] }); } return undefined; }); await actionCable.onMessageCreated(message); expect(mockDispatch).toHaveBeenCalledWith('getConversation', 7); expect(DashboardAudioNotificationHelper.onNewMessage).toHaveBeenCalledWith( message ); }); it('hydrates a conversation-created event that has no embedded messages', async () => { mockDispatch.mockImplementation(action => { if (action === 'addConversation') return Promise.resolve(true); if (action === 'getConversation') return Promise.resolve({ id: 7 }); return undefined; }); await actionCable.onConversationCreated({ id: 7, inbox_id: 1, messages: [], meta: { sender: {} }, }); expect(mockDispatch).toHaveBeenCalledWith('getConversation', 7); }); it('plays new message audio only once when realtime replays a message', async () => { const message = { id: 42, account_id: 1, conversation_id: 7, conversation: { last_activity_at: 123 }, }; const addMessageResults = [true, false]; mockDispatch.mockImplementation(action => Promise.resolve( action === 'addMessage' ? addMessageResults.shift() : undefined ) ); await actionCable.onMessageCreated(message); await actionCable.onMessageCreated(message); expect(DashboardAudioNotificationHelper.onNewMessage).toHaveBeenCalledTimes( 1 ); }); it('does not apply replayed or 1e10-millisecond activity timestamps', async () => { const currentMessage = { id: 42, conversation_id: 7, updated_at: '2026-08-24T02:00:00Z', }; const state = { allConversations: [ { id: 7, messages: [currentMessage], last_activity_at: 200, }, ], selectedChatId: null, }; useRealConversationDispatch(state); await actionCable.onMessageCreated({ ...currentMessage, conversation: { last_activity_at: 100 }, }); await actionCable.onMessageCreated({ ...currentMessage, updated_at: 10_000_000_000, conversation: { last_activity_at: 50 }, }); expect(state.allConversations[0].last_activity_at).toBe(200); expect( DashboardAudioNotificationHelper.onNewMessage ).not.toHaveBeenCalled(); }); it('rejects malformed timestamps without updating conversation activity', async () => { const currentMessage = { id: 42, conversation_id: 7, content: 'current', updated_at: '2026-08-24T02:00:00Z', }; const state = { allConversations: [ { id: 7, messages: [currentMessage], last_activity_at: 200 }, ], selectedChatId: null, }; useRealConversationDispatch(state); await actionCable.onMessageCreated({ ...currentMessage, content: 'malformed', updated_at: 'not-a-timestamp', conversation: { last_activity_at: 300 }, }); expect(state.allConversations[0].messages[0]).toBe(currentMessage); expect(state.allConversations[0].last_activity_at).toBe(200); expect( DashboardAudioNotificationHelper.onNewMessage ).not.toHaveBeenCalled(); }); it('keeps accepting events with a missing timestamp', async () => { const state = { allConversations: [ { id: 7, messages: [ { id: 42, conversation_id: 7, content: 'current', updated_at: '2026-08-24T02:00:00Z', }, ], last_activity_at: 200, }, ], selectedChatId: null, }; useRealConversationDispatch(state); const message = { id: 42, conversation_id: 7, content: 'compatible update', conversation: { last_activity_at: 300 }, }; await actionCable.onMessageCreated(message); expect(state.allConversations[0].messages[0]).toEqual(message); expect(state.allConversations[0].last_activity_at).toBe(300); expect(DashboardAudioNotificationHelper.onNewMessage).toHaveBeenCalledWith( message ); }); it('skips dashboard side effects when the conversation mutation is a no-op', async () => { setActivePinia(createPinia()); const state = { allConversations: [], selectedChatId: null }; useRealConversationDispatch(state); await actionCable.onMessageCreated({ id: 42, conversation_id: 7, message_type: 1, content_type: 'voice_call', updated_at: '2026-08-24T02:00:00Z', conversation: { last_activity_at: 200 }, call: { provider_call_id: 'call-1', direction: 'incoming', }, }); expect(state.allConversations).toEqual([]); expect( DashboardAudioNotificationHelper.onNewMessage ).not.toHaveBeenCalled(); expect(useCallsStore().calls).toEqual([]); }); it('applies a newer event and runs existing dashboard side effects', async () => { setActivePinia(createPinia()); const state = { allConversations: [ { id: 7, messages: [ { id: 42, conversation_id: 7, updated_at: '1787533200', }, ], last_activity_at: 100, }, ], selectedChatId: null, }; useRealConversationDispatch(state); const message = { id: 42, conversation_id: 7, message_type: 1, content: 'newer', content_type: 'voice_call', updated_at: '2026-08-24T02:00:00Z', conversation: { last_activity_at: 200 }, call: { provider_call_id: 'call-1', direction: 'incoming', }, }; await actionCable.onMessageCreated(message); expect(state.allConversations[0].messages[0]).toEqual(message); expect(state.allConversations[0].last_activity_at).toBe(200); expect(DashboardAudioNotificationHelper.onNewMessage).toHaveBeenCalledWith( message ); expect(useCallsStore().calls).toHaveLength(1); }); describe('conversation unread count event handlers', () => { it('should register the conversation.unread_count_changed event handler', () => { expect(Object.keys(actionCable.events)).toContain( 'conversation.unread_count_changed' ); expect(actionCable.events['conversation.unread_count_changed']).toBe( actionCable.onConversationUnreadCountChanged ); }); it('should refetch unread counts when unread count changes', () => { actionCable.onReceived({ event: 'conversation.unread_count_changed', data: { account_id: 1 }, }); expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get'); }); it('does not refetch unread counts when unread count feature is disabled', () => { store.$store.getters[ 'accounts/isFeatureEnabledonAccount' ].mockReturnValue(false); actionCable.onReceived({ event: 'conversation.unread_count_changed', data: { account_id: 1 }, }); expect(mockDispatch).not.toHaveBeenCalledWith( 'conversationUnreadCounts/get' ); }); it('should throttle unread count refetches for repeated events', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); actionCable.onReceived({ event: 'conversation.unread_count_changed', data: { account_id: 1 }, }); actionCable.onReceived({ event: 'conversation.unread_count_changed', data: { account_id: 1 }, }); actionCable.onReceived({ event: 'conversation.unread_count_changed', data: { account_id: 1 }, }); expect(mockDispatch).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(4999); expect(mockDispatch).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1); expect(mockDispatch).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenLastCalledWith( 'conversationUnreadCounts/get' ); }); it('clears pending unread count refetch before immediate refetch', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); actionCable.onReceived({ event: 'conversation.unread_count_changed', data: { account_id: 1 }, }); vi.advanceTimersByTime(1000); actionCable.onReceived({ event: 'conversation.unread_count_changed', data: { account_id: 1 }, }); vi.setSystemTime(new Date('2026-01-01T00:00:06Z')); actionCable.onReceived({ event: 'conversation.unread_count_changed', data: { account_id: 1 }, }); expect(mockDispatch).toHaveBeenCalledTimes(2); vi.advanceTimersByTime(4000); expect(mockDispatch).toHaveBeenCalledTimes(2); }); }); it('updates AI takeover state from conversation.updated', () => { const conversation = { id: 42, account_id: 1, ai_takeover_active: false, }; actionCable.onReceived({ event: 'conversation.updated', data: conversation, }); expect(mockDispatch).toHaveBeenCalledWith( 'updateConversation', conversation ); }); });