fix(HH-576): normalize realtime message timestamps (#148)
Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
@@ -158,6 +158,70 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
).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 };
|
||||
@@ -193,7 +257,7 @@ describe('ActionCableConnector - Copilot Tests', () => {
|
||||
{
|
||||
id: 42,
|
||||
conversation_id: 7,
|
||||
updated_at: '2026-08-24T01:00:00Z',
|
||||
updated_at: '1787533200',
|
||||
},
|
||||
],
|
||||
last_activity_at: 100,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
handleVoiceCallUpdated,
|
||||
syncConversationCallVisibility,
|
||||
} from 'dashboard/helper/voice';
|
||||
import { isNewerTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||
// This helper is used to check if the message has failed with an external error.
|
||||
@@ -41,9 +42,7 @@ const getStoredMessage = (conversation, message) => {
|
||||
};
|
||||
|
||||
const isNewerMessage = (currentMessage, message) =>
|
||||
!currentMessage?.updated_at ||
|
||||
!message.updated_at ||
|
||||
message.updated_at > currentMessage.updated_at;
|
||||
isNewerTimestamp(currentMessage?.updated_at, message.updated_at);
|
||||
|
||||
// actions
|
||||
const actions = {
|
||||
|
||||
@@ -6,6 +6,28 @@ import {
|
||||
differenceInDays,
|
||||
} from 'date-fns';
|
||||
|
||||
const normalizeTimestamp = value => {
|
||||
const numericValue =
|
||||
typeof value === 'string' && value.trim() ? Number(value) : value;
|
||||
if (typeof numericValue === 'number' && Number.isFinite(numericValue)) {
|
||||
return Math.abs(numericValue) < 1e12 ? numericValue * 1000 : numericValue;
|
||||
}
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
};
|
||||
|
||||
export const isNewerTimestamp = (currentTimestamp, nextTimestamp) => {
|
||||
if (currentTimestamp == null || nextTimestamp == null) return true;
|
||||
|
||||
const next = normalizeTimestamp(nextTimestamp);
|
||||
if (next === null) return false;
|
||||
|
||||
const current = normalizeTimestamp(currentTimestamp);
|
||||
return current === null || next > current;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp into a human-readable time format.
|
||||
* @param {number} time - Unix timestamp.
|
||||
|
||||
@@ -3,6 +3,8 @@ import ActionCableConnector from '../actionCable';
|
||||
import { playNewMessageNotificationInWidget } from '../WidgetAudioNotificationHelper';
|
||||
import { IFrameHelper } from '../utils';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { actions } from 'widget/store/modules/conversation/actions';
|
||||
import { mutations } from 'widget/store/modules/conversation/mutations';
|
||||
|
||||
vi.mock('../WidgetAudioNotificationHelper', () => ({
|
||||
playNewMessageNotificationInWidget: vi.fn(),
|
||||
@@ -41,6 +43,16 @@ describe('Widget ActionCableConnector', () => {
|
||||
);
|
||||
});
|
||||
|
||||
const useRealConversationDispatch = state => {
|
||||
const commit = (type, payload) => mutations[type](state, payload);
|
||||
dispatch.mockImplementation((action, payload) => {
|
||||
if (action === 'conversation/addOrUpdateMessage') {
|
||||
return actions.addOrUpdateMessage({ commit, state }, payload);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
it('emits message side effects only once when realtime replays a message', async () => {
|
||||
const message = {
|
||||
id: 42,
|
||||
@@ -73,4 +85,102 @@ describe('Widget ActionCableConnector', () => {
|
||||
|
||||
expect(IFrameHelper.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed timestamps through the real widget mutation path', async () => {
|
||||
const currentMessage = {
|
||||
id: 42,
|
||||
conversation_id: 7,
|
||||
content: 'current',
|
||||
updated_at: '2026-08-24T02:00:00Z',
|
||||
};
|
||||
const state = { conversations: { 42: currentMessage } };
|
||||
useRealConversationDispatch(state);
|
||||
|
||||
await actionCable.onMessageUpdated({
|
||||
...currentMessage,
|
||||
content: 'malformed',
|
||||
updated_at: 'not-a-timestamp',
|
||||
previous_changes: {
|
||||
content_attributes: [{}, { submitted_values: ['yes'] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.conversations[42]).toBe(currentMessage);
|
||||
expect(IFrameHelper.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an older numeric-string timestamp against an ISO timestamp', async () => {
|
||||
const currentMessage = {
|
||||
id: 42,
|
||||
conversation_id: 7,
|
||||
content: 'current',
|
||||
updated_at: '2026-08-24T02:00:00Z',
|
||||
};
|
||||
const state = { conversations: { 42: currentMessage } };
|
||||
useRealConversationDispatch(state);
|
||||
|
||||
await actionCable.onMessageUpdated({
|
||||
...currentMessage,
|
||||
content: 'older',
|
||||
updated_at: '1787533200',
|
||||
previous_changes: {
|
||||
content_attributes: [{}, { submitted_values: ['yes'] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.conversations[42]).toBe(currentMessage);
|
||||
expect(IFrameHelper.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces a malformed stored timestamp with a valid update', async () => {
|
||||
const state = {
|
||||
conversations: {
|
||||
42: {
|
||||
id: 42,
|
||||
conversation_id: 7,
|
||||
content: 'malformed stored message',
|
||||
updated_at: 'not-a-timestamp',
|
||||
},
|
||||
},
|
||||
};
|
||||
useRealConversationDispatch(state);
|
||||
const message = {
|
||||
id: 42,
|
||||
conversation_id: 7,
|
||||
content: 'valid update',
|
||||
updated_at: '2026-08-24T02:00:00Z',
|
||||
previous_changes: {
|
||||
content_attributes: [{}, { submitted_values: ['yes'] }],
|
||||
},
|
||||
};
|
||||
|
||||
await actionCable.onMessageUpdated(message);
|
||||
|
||||
expect(state.conversations[42]).toEqual(message);
|
||||
expect(IFrameHelper.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps accepting widget updates with a missing timestamp', async () => {
|
||||
const currentMessage = {
|
||||
id: 42,
|
||||
conversation_id: 7,
|
||||
content: 'current',
|
||||
updated_at: '2026-08-24T02:00:00Z',
|
||||
};
|
||||
const state = { conversations: { 42: currentMessage } };
|
||||
useRealConversationDispatch(state);
|
||||
const message = {
|
||||
id: 42,
|
||||
conversation_id: 7,
|
||||
content: 'compatible update',
|
||||
previous_changes: {
|
||||
content_attributes: [{}, { submitted_values: ['yes'] }],
|
||||
},
|
||||
};
|
||||
|
||||
await actionCable.onMessageUpdated(message);
|
||||
|
||||
expect(state.conversations[42]).toEqual(message);
|
||||
expect(IFrameHelper.sendMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { ON_CONVERSATION_CREATED } from 'widget/constants/widgetBusEvents';
|
||||
import { createTemporaryMessage, getNonDeletedMessages } from './helpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { isNewerTimestamp } from 'shared/helpers/timeHelper';
|
||||
export const actions = {
|
||||
createConversation: async ({ commit, dispatch }, params) => {
|
||||
commit('setConversationUIFlag', { isCreating: true });
|
||||
@@ -178,9 +179,8 @@ export const actions = {
|
||||
const currentMessage = state?.conversations?.[id];
|
||||
const isNew = !currentMessage;
|
||||
if (
|
||||
currentMessage?.updated_at &&
|
||||
data.updated_at &&
|
||||
data.updated_at <= currentMessage.updated_at
|
||||
currentMessage &&
|
||||
!isNewerTimestamp(currentMessage.updated_at, data.updated_at)
|
||||
) {
|
||||
return { applied: false, isNew };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user