Files
gochat/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js
T
2026-08-24 11:13:53 +08:00

402 lines
11 KiB
JavaScript

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('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
);
});
});