HH-569: guard replayed frontend realtime messages (#144)

* fix(HH-569): guard replayed realtime messages

* fix(HH-569): gate dashboard message side effects

---------

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-24 00:42:05 +08:00
committed by GitHub
co-authored by rogee
parent 88ac30d0b8
commit 84a1e35294
8 changed files with 398 additions and 15 deletions
@@ -100,13 +100,15 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
onLogout = () => AuthAPI.logout();
onMessageCreated = data => {
onMessageCreated = async data => {
const {
conversation: { last_activity_at: lastActivityAt },
conversation_id: conversationId,
} = data;
const applied = await this.app.$store.dispatch('addMessage', data);
if (!applied) return;
DashboardAudioNotificationHelper.onNewMessage(data);
this.app.$store.dispatch('addMessage', data);
this.app.$store.dispatch('updateConversationLastActivity', {
lastActivityAt,
conversationId,
@@ -1,5 +1,16 @@
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: {
@@ -41,6 +52,27 @@ describe('ActionCableConnector - Copilot Tests', () => {
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(
@@ -70,6 +102,130 @@ describe('ActionCableConnector - Copilot Tests', () => {
});
});
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 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: '2026-08-24T01:00:00Z',
conversation: { last_activity_at: 50 },
});
expect(state.allConversations[0].last_activity_at).toBe(200);
expect(
DashboardAudioNotificationHelper.onNewMessage
).not.toHaveBeenCalled();
});
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: '2026-08-24T01:00:00Z',
},
],
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(