diff --git a/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js b/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js new file mode 100644 index 000000000..ac9b56c53 --- /dev/null +++ b/src/features/modules/chat/modules/closed/store/__tests__/closed.spec.js @@ -0,0 +1,266 @@ +import { Conversation } from 'webitel-sdk'; + +import closedModule from '../closed'; + +describe('features/chat/closed store: actions', () => { + let context; + + beforeEach(() => { + context = { + rootState: { + features: { + chat: { + chatHistory: { + next: false, + }, + }, + }, + }, + dispatch: vi.fn().mockResolvedValue(undefined), + commit: vi.fn(), + }; + }); + + describe('OPEN_CLOSED_CHAT', () => { + it('dispatches LOAD_CLOSED_CHAT for a REST stub without an identified contact', async () => { + const chat = { + id: '1', + closedAt: Date.now(), + }; + await closedModule.actions.OPEN_CLOSED_CHAT(context, chat); + expect(context.dispatch).toHaveBeenCalledWith('LOAD_CLOSED_CHAT', chat); + }); + + it('dispatches SET_WORKSPACE directly for a REST stub with an identified contact', async () => { + const chat = { + id: '1', + closedAt: Date.now(), + contact: { + id: 'contact-1', + }, + }; + await closedModule.actions.OPEN_CLOSED_CHAT(context, chat); + expect(context.dispatch).toHaveBeenCalledWith( + 'features/chat/SET_WORKSPACE', + chat, + { + root: true, + }, + ); + expect(context.dispatch).not.toHaveBeenCalledWith( + 'LOAD_CLOSED_CHAT', + expect.anything(), + ); + }); + + it('dispatches SET_WORKSPACE for a live SDK Conversation even when contact.id is unset (post-processing) [WTEL-9955]', async () => { + // a live SDK Conversation instance, not a plain REST object; + // `id` is a getter derived from `channelId`, so set that instead + const chat = Object.assign(new Conversation(), { + channelId: 'channel-1', + closedAt: Date.now(), + contact: { + id: null, + }, + }); + + await closedModule.actions.OPEN_CLOSED_CHAT(context, chat); + + expect(context.dispatch).toHaveBeenCalledWith( + 'features/chat/SET_WORKSPACE', + chat, + { + root: true, + }, + ); + expect(context.dispatch).not.toHaveBeenCalledWith( + 'LOAD_CLOSED_CHAT', + expect.anything(), + ); + }); + }); + + describe('LOAD_CLOSED_CHAT_HISTORY', () => { + it('loads history for the given contactId (not chat.contact.id) and searches for the target chat', async () => { + const chat = { + id: 'channel-1', + conversationId: 'conv-1', + // chat.contact.id is null here on purpose: the action must rely on + // the passed-in contactId instead, see WTEL-9955 + contact: { + id: null, + }, + }; + const contactId = 'contact-1'; + + await closedModule.actions.LOAD_CLOSED_CHAT_HISTORY(context, { + chat, + contactId, + }); + + expect(context.dispatch).toHaveBeenCalledWith('RESET_CLOSED_CHAT'); + expect(context.dispatch).toHaveBeenCalledWith( + 'features/chat/chatHistory/LOAD_CHAT_HISTORY', + contactId, + { + root: true, + }, + ); + expect(context.dispatch).toHaveBeenCalledWith( + 'FIND_TARGET_CHAT_IN_HISTORY', + { + chat, + contactId, + }, + ); + expect(context.commit).toHaveBeenCalledWith( + 'SET_IS_CLOSED_CHAT_LOADED', + true, + ); + }); + + it('still marks the closed chat as loaded when loading history fails', async () => { + const err = new Error('network error'); + context.dispatch.mockImplementation((action) => { + if (action === 'features/chat/chatHistory/LOAD_CHAT_HISTORY') { + return Promise.reject(err); + } + return Promise.resolve(); + }); + + await expect( + closedModule.actions.LOAD_CLOSED_CHAT_HISTORY(context, { + chat: { + id: 'channel-1', + }, + contactId: 'contact-1', + }), + ).rejects.toBeTruthy(); + + expect(context.commit).toHaveBeenCalledWith( + 'SET_IS_CLOSED_CHAT_LOADED', + true, + ); + }); + }); + + describe('FIND_TARGET_CHAT_IN_HISTORY', () => { + it('returns early without dispatching anything when there is no next history page', async () => { + context.rootState.features.chat.chatHistory.next = false; + const chat = { + id: 'channel-1', + conversationId: 'conv-1', + }; + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat, + contactId: 'contact-1', + }); + + expect(context.dispatch).not.toHaveBeenCalled(); + }); + + it('looks up the target message by conversationId, not by the live chat.id [WTEL-9955]', async () => { + context.rootState.features.chat.chatHistory.next = true; + // chat.id is the live channelId, which never appears as message.chat.id + // in chatHistory data — conversationId does (see ContactChatMessagesAPI) + const chat = { + id: 'channel-1', + conversationId: 'conv-1', + }; + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat, + contactId: 'contact-1', + }); + + expect(context.dispatch).toHaveBeenCalledWith( + 'FIND_TARGET_CHAT_FIRST_MESSAGE', + 'conv-1', + ); + }); + + it('falls back to chat.id when conversationId is missing', async () => { + context.rootState.features.chat.chatHistory.next = true; + const chat = { + id: 'channel-1', + }; + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat, + contactId: 'contact-1', + }); + + expect(context.dispatch).toHaveBeenCalledWith( + 'FIND_TARGET_CHAT_FIRST_MESSAGE', + 'channel-1', + ); + }); + + it('commits the found message id and does not load another history page once the target chat is found', async () => { + context.rootState.features.chat.chatHistory.next = true; + const foundMessage = { + id: 'message-1', + }; + context.dispatch.mockImplementation((action) => { + if (action === 'FIND_TARGET_CHAT_FIRST_MESSAGE') { + return Promise.resolve(foundMessage); + } + return Promise.resolve(); + }); + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat: { + id: 'channel-1', + conversationId: 'conv-1', + }, + contactId: 'contact-1', + }); + + expect(context.commit).toHaveBeenCalledWith( + 'SET_CLOSED_CHAT_FIRST_MESSAGE_ID', + foundMessage.id, + ); + expect(context.dispatch).not.toHaveBeenCalledWith( + 'features/chat/chatHistory/LOAD_NEXT', + expect.anything(), + expect.anything(), + ); + }); + + it('loads the next history page and recurses with the same contactId when the target chat is not found yet', async () => { + context.rootState.features.chat.chatHistory.next = true; + context.dispatch.mockImplementation((action) => { + if (action === 'FIND_TARGET_CHAT_FIRST_MESSAGE') { + return Promise.resolve(undefined); + } + return Promise.resolve(); + }); + const chat = { + id: 'channel-1', + conversationId: 'conv-1', + }; + const contactId = 'contact-1'; + + await closedModule.actions.FIND_TARGET_CHAT_IN_HISTORY(context, { + chat, + contactId, + }); + + expect(context.dispatch).toHaveBeenCalledWith( + 'features/chat/chatHistory/LOAD_NEXT', + contactId, + { + root: true, + }, + ); + expect(context.dispatch).toHaveBeenCalledWith( + 'FIND_TARGET_CHAT_IN_HISTORY', + { + chat, + contactId, + }, + ); + }); + }); +}); diff --git a/src/features/modules/chat/modules/closed/store/closed.js b/src/features/modules/chat/modules/closed/store/closed.js index f5ba9f0f4..41521214f 100644 --- a/src/features/modules/chat/modules/closed/store/closed.js +++ b/src/features/modules/chat/modules/closed/store/closed.js @@ -1,6 +1,7 @@ import applyTransform, { notify, } from '@webitel/ui-sdk/src/api/transformers/index'; +import { Conversation } from 'webitel-sdk'; import AgentChatsAPI from '../../../../../../app/api/agent-workspace/endpoints/agent-info/agent-chats'; import CatalogAPI from '../../../../../../app/api/agent-workspace/endpoints/catalog/CatalogAPIRepository'; @@ -138,7 +139,16 @@ const actions = { } }, OPEN_CLOSED_CHAT: async (context, chat) => { - if (!chat.contact?.id) { + /** + * @author @OleksandrPalonnyi + * + * [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) + * + * see OPEN_CHAT in features/chat/store/chat.js — same reasoning for + * distinguishing a REST closed-chat stub from a live SDK instance. + */ + const isChatFromRestApi = !(chat instanceof Conversation); + if (isChatFromRestApi && !chat.contact?.id) { await context.dispatch('LOAD_CLOSED_CHAT', chat); } else { context.commit('SET_CLOSED_CHAT_FIRST_MESSAGE_ID', null); @@ -148,10 +158,18 @@ const actions = { }); } }, - LOAD_CLOSED_CHAT_HISTORY: async (context, chat) => { - const contactId = chat.contact.id; - const targetChatId = chat.id; - + /** + * @author @OleksandrPalonnyi + * + * [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) + * + * chat here comes from the CHAT_ON_WORKSPACE getter, which can hold either + * a chat or a task during the closing/post-processing lifecycle, so its id + * and contact are unreliable and can go missing between renders. contactId + * is captured from the contact prop when history loading starts and passed + * through explicitly instead of being re-read off chat later. + */ + LOAD_CLOSED_CHAT_HISTORY: async (context, { chat, contactId }) => { try { context.dispatch('RESET_CLOSED_CHAT'); await context.dispatch( @@ -162,7 +180,10 @@ const actions = { }, ); - await context.dispatch('FIND_TARGET_CHAT_IN_HISTORY', chat); + await context.dispatch('FIND_TARGET_CHAT_IN_HISTORY', { + chat, + contactId, + }); } catch (err) { throw applyTransform(err, [ notify, @@ -172,10 +193,9 @@ const actions = { } }, - FIND_TARGET_CHAT_IN_HISTORY: async (context, chat) => { + FIND_TARGET_CHAT_IN_HISTORY: async (context, { chat, contactId }) => { // recursive function - const contactId = chat.contact.id; - const targetChatId = chat.id; + const targetChatId = chat.conversationId || chat.id; const next = context.rootState.features.chat.chatHistory.next; if (!next) return; @@ -196,7 +216,10 @@ const actions = { await context.dispatch('features/chat/chatHistory/LOAD_NEXT', contactId, { root: true, }); - await context.dispatch('FIND_TARGET_CHAT_IN_HISTORY', chat); // call itself until find target chat + await context.dispatch('FIND_TARGET_CHAT_IN_HISTORY', { + chat, + contactId, + }); // call itself until find target chat }, FIND_TARGET_CHAT_FIRST_MESSAGE: async (context, targetChatId) => { // try to find first message of needed chat diff --git a/src/features/modules/chat/store/chat.js b/src/features/modules/chat/store/chat.js index 85cb77b62..8694b9c91 100644 --- a/src/features/modules/chat/store/chat.js +++ b/src/features/modules/chat/store/chat.js @@ -1,5 +1,5 @@ import { applyTransform, notify } from '@webitel/api-services/api/transformers'; -import { ConversationState } from 'webitel-sdk'; +import { Conversation, ConversationState } from 'webitel-sdk'; import i18n from '../../../../app/locale/i18n'; import WorkspaceStates from '../../../../ui/enums/WorkspaceState.enum'; @@ -143,7 +143,23 @@ const actions = { }, OPEN_CHAT: async (context, chat) => { - const isUnidentifiedClosedChat = !chat.contact?.id && chat.closedAt; + /** + * @author @OleksandrPalonnyi + * + * [WTEL-9955](https://webitel.atlassian.net/browse/WTEL-9955) + * + * A live SDK `Conversation` can have `contact.id` unset too (e.g. during + * post-processing), so `!chat.contact?.id` alone can't tell a REST closed-chat + * stub from a live instance. `chat instanceof Conversation` checks directly + * against the only place a live instance is ever built + * (buildConversationFromDialog.js) instead of inferring it from the shape of + * a plain object. Only a REST stub needs the LOAD_CLOSED_CHAT reload — + * spreading a live Conversation into a plain object drops its prototype + * getters (`id`, `allowReporting`, …). + */ + const isChatFromRestApi = !(chat instanceof Conversation); + const isUnidentifiedClosedChat = + isChatFromRestApi && !chat.contact?.id && chat.closedAt; if (isUnidentifiedClosedChat) { await context.dispatch('features/chat/closed/LOAD_CLOSED_CHAT', chat, { diff --git a/src/ui/modules/work-section/modules/chat/chat-messaging/chat-history/the-chat-history.vue b/src/ui/modules/work-section/modules/chat/chat-messaging/chat-history/the-chat-history.vue index e94c246e2..a444b74d5 100644 --- a/src/ui/modules/work-section/modules/chat/chat-messaging/chat-history/the-chat-history.vue +++ b/src/ui/modules/work-section/modules/chat/chat-messaging/chat-history/the-chat-history.vue @@ -189,10 +189,10 @@ const { startObserve: startObserveClosedChat } = useObserveHeightUntilStable( const loadHistory = async () => await store.dispatch(`${namespace}/LOAD_CHAT_HISTORY`, props.contact?.id); const loadClosedChatHistory = async () => - await store.dispatch( - `features/chat/closed/LOAD_CLOSED_CHAT_HISTORY`, - chat.value, - ); + await store.dispatch(`features/chat/closed/LOAD_CLOSED_CHAT_HISTORY`, { + chat: chat.value, + contactId: props.contact?.id, + }); const resetHistory = () => store.dispatch(`${namespace}/RESET_CHAT_HISTORY`); const attachPlayer = (player) =>