diff --git a/packages/chat-core-zendesk/package.json b/packages/chat-core-zendesk/package.json index 799ec4f..3261a5c 100644 --- a/packages/chat-core-zendesk/package.json +++ b/packages/chat-core-zendesk/package.json @@ -1,6 +1,6 @@ { "name": "@yext/chat-core-zendesk", - "version": "0.3.0", + "version": "0.3.1", "description": "Typescript Networking Library for the Yext Chat API Integration with Zendesk", "main": "./dist/commonjs/index.js", "module": "./dist/esm/index.mjs", diff --git a/packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts b/packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts index 2843bd3..d700471 100644 --- a/packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts +++ b/packages/chat-core-zendesk/src/infra/ChatCoreZendeskImpl.ts @@ -63,7 +63,7 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk { this.tags = [...new Set(this.tags)]; this.jwt = config.jwt; this.externalId = config.externalId; - this.onInvalidAuth = config.onInvalidAuth + this.onInvalidAuth = config.onInvalidAuth; } /** @@ -80,7 +80,9 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk { private async initializeZendeskSdk(): Promise { const divId = "yext-chat-core-zendesk-container"; + // If the div already exists, assume the SDK is already initialized if (window.document.getElementById(divId)) { + this.setupEventListeners(); return; } const div = window.document.createElement("div"); @@ -101,13 +103,15 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk { delegate: { onInvalidAuth: this.onInvalidAuth, }, - }).then(() => { - this.setupEventListeners(); - resolve(); - }).catch((e) => { - console.error("Zendesk SDK init error", e); - reject(e); - }); + }) + .then(() => { + this.setupEventListeners(); + resolve(); + }) + .catch((e) => { + console.error("Zendesk SDK init error", e); + reject(e); + }); }); } @@ -167,9 +171,14 @@ export class ChatCoreZendeskImpl implements ChatCoreZendesk { } private setupEventListeners() { + // @ts-ignore - off() is not in the Smooch types, but does exist + Smooch.off(); // Unbind all previous event listeners, if any, before setting up new ones Smooch.on( "message:received", (message: Message, data: ConversationData) => { + if (data.conversation.id !== this.conversationId) { + return; + } if (message.type !== "text" || message.role !== "business") { return; } diff --git a/packages/chat-core-zendesk/src/models/ChatCoreZendeskConfig.ts b/packages/chat-core-zendesk/src/models/ChatCoreZendeskConfig.ts index 1cc9e36..9d41f95 100644 --- a/packages/chat-core-zendesk/src/models/ChatCoreZendeskConfig.ts +++ b/packages/chat-core-zendesk/src/models/ChatCoreZendeskConfig.ts @@ -14,14 +14,14 @@ export interface ChatCoreZendeskConfig { ticketTags?: string[]; /** * The JWT token to authenticate the user with Zendesk. - * + * * @remarks * Should be provided along with the {@link ChatCoreZendeskConfig.externalId} to authenticate the user. */ jwt?: string; /** * The external ID to associate with the user in Zendesk. - * + * * @remarks * Should be provided along with the {@link ChatCoreZendeskConfig.jwt} token to authenticate the user. */ diff --git a/packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts b/packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts index cedc305..d5229c5 100644 --- a/packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts +++ b/packages/chat-core-zendesk/tests/ChatCoreZendesk.test.ts @@ -40,9 +40,7 @@ beforeEach(() => { jest .mocked(SmoochLib.createConversation) .mockResolvedValue({ id: mockConversationId } as Conversation); - jest - .mocked(SmoochLib.init) - .mockResolvedValue(Promise.resolve()); + jest.mocked(SmoochLib.init).mockResolvedValue(Promise.resolve()); document.body.innerHTML = ""; }); @@ -96,129 +94,172 @@ describe("chat session initialization", () => { mockConversationId ); }); -}); -it("emits typing event", async () => { - const startTypingSpy = jest.spyOn(SmoochLib, "startTyping"); - const stopTypingSpy = jest.spyOn(SmoochLib, "stopTyping"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); + it("sets event listeners on chat session re-initialization", async () => { + const onCbSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + const dummyFn = jest.fn(); + chatCoreZendesk.on("message", dummyFn); - chatCoreZendesk.emit("typing", true); - expect(startTypingSpy).toBeCalledTimes(1); - expect(stopTypingSpy).toBeCalledTimes(0); + // first initialization, should receive message from the created conversation + const firstConvoId = "first-convo-id"; + jest + .mocked(SmoochLib.createConversation) + .mockResolvedValue({ id: firstConvoId } as Conversation); + await chatCoreZendesk.init(mockMessageResponse()); + // get "message:received" callback + const onMessageFn = onCbSpy.mock.calls[0][1] as any; + // simulate a message event + onMessageFn( + { text: "message1", type: "text", role: "business" }, + { conversation: { id: firstConvoId } } + ); + expect(dummyFn).toBeCalledWith("message1"); - chatCoreZendesk.emit("typing", false); - expect(startTypingSpy).toBeCalledTimes(1); - expect(stopTypingSpy).toBeCalledTimes(1); + // re-initialization, should receive ONLY message from the new conversation + dummyFn.mockClear(); + const secondConvoId = "second-convo-id"; + jest + .mocked(SmoochLib.createConversation) + .mockResolvedValue({ id: secondConvoId } as Conversation); + await chatCoreZendesk.init(mockMessageResponse()); + // simulate a message event from old convo and new convo + onMessageFn( + { text: "message1", type: "text", role: "business" }, + { conversation: { id: firstConvoId } } + ); + expect(dummyFn).not.toBeCalled(); + onMessageFn( + { text: "message2", type: "text", role: "business" }, + { conversation: { id: secondConvoId } } + ); + expect(dummyFn).toBeCalledWith("message2"); + }); }); -it("sends message on processMessage", async () => { - const sendMessageSpy = jest.spyOn(SmoochLib, "sendMessage"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); +describe("chat session events", () => { + it("emits typing event", async () => { + const startTypingSpy = jest.spyOn(SmoochLib, "startTyping"); + const stopTypingSpy = jest.spyOn(SmoochLib, "stopTyping"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); - const msg = "hello world!"; - await chatCoreZendesk.processMessage({ - messages: [ - { - source: "USER", - text: msg, - }, - ], + chatCoreZendesk.emit("typing", true); + expect(startTypingSpy).toBeCalledTimes(1); + expect(stopTypingSpy).toBeCalledTimes(0); + + chatCoreZendesk.emit("typing", false); + expect(startTypingSpy).toBeCalledTimes(1); + expect(stopTypingSpy).toBeCalledTimes(1); }); - expect(sendMessageSpy).toBeCalledWith(msg, mockConversationId); -}); -it("returns session on getSession", async () => { - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); - expect(chatCoreZendesk.getSession()).toBe(mockConversationId); -}); + it("sends message on processMessage", async () => { + const sendMessageSpy = jest.spyOn(SmoochLib, "sendMessage"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); -it("triggers message event callbacks", async () => { - const onCbSpy = jest.spyOn(SmoochLib, "on"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); + const msg = "hello world!"; + await chatCoreZendesk.processMessage({ + messages: [ + { + source: "USER", + text: msg, + }, + ], + }); + expect(sendMessageSpy).toBeCalledWith(msg, mockConversationId); + }); - const text = "hello world!"; - const dummyFn = jest.fn(); - chatCoreZendesk.on("message", dummyFn); - expect(onCbSpy).toBeCalled(); - - // get "message:received" callback - const onMessageFn = onCbSpy.mock.calls[0][1] as any; - // simulate a message event - onMessageFn( - { text, type: "text", role: "business" }, - { conversation: { id: mockConversationId } } - ); - expect(dummyFn).toBeCalledWith(text); -}); + it("triggers message event callbacks", async () => { + const onCbSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); -it("triggers close event callbacks", async () => { - const onCbSpy = jest.spyOn(SmoochLib, "on"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); + const text = "hello world!"; + const dummyFn = jest.fn(); + chatCoreZendesk.on("message", dummyFn); + expect(onCbSpy).toBeCalled(); - const text = "hello world!"; - const conversation = { conversation: { id: mockConversationId } }; - const dummyFn = jest.fn(); - chatCoreZendesk.on("close", dummyFn); - expect(onCbSpy).toBeCalled(); - - // get "message:received" callback - const onCloseFn = onCbSpy.mock.calls[0][1] as any; - // simulate a message event from internal bot indicating agent has left - onCloseFn( - { text, type: "text", role: "business", subroles: ["AI"] }, - conversation - ); - expect(dummyFn).toBeCalledWith(conversation); -}); + // get "message:received" callback + const onMessageFn = onCbSpy.mock.calls[0][1] as any; + // simulate a message event + onMessageFn( + { text, type: "text", role: "business" }, + { conversation: { id: mockConversationId } } + ); + expect(dummyFn).toBeCalledWith(text); + }); -it("triggers typing event callbacks", async () => { - const onTypingSpy = jest.spyOn(SmoochLib, "on"); - const chatCoreZendesk = provideChatCoreZendesk(mockConfig); - await chatCoreZendesk.init(mockMessageResponse()); + it("triggers close event callbacks", async () => { + const onCbSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); + + const text = "hello world!"; + const conversation = { conversation: { id: mockConversationId } }; + const dummyFn = jest.fn(); + chatCoreZendesk.on("close", dummyFn); + expect(onCbSpy).toBeCalled(); + + // get "message:received" callback + const onCloseFn = onCbSpy.mock.calls[0][1] as any; + // simulate a message event from internal bot indicating agent has left + onCloseFn( + { text, type: "text", role: "business", subroles: ["AI"] }, + conversation + ); + expect(dummyFn).toBeCalledWith(conversation); + }); + + it("triggers typing event callbacks", async () => { + const onTypingSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); + + const dummyFn = jest.fn(); + chatCoreZendesk.on("typing", dummyFn); + expect(onTypingSpy).toBeCalled(); + + // get "typing:start" callback + const onStartTypingFn = onTypingSpy.mock.calls[1][1] as any; + // simulate a typing event + onStartTypingFn(); + expect(dummyFn).toBeCalledWith(true); + + // get "typing:stop" callback + const onStopTypingFn = onTypingSpy.mock.calls[2][1] as any; + // simulate a typing event + onStopTypingFn(); + expect(dummyFn).toBeCalledWith(false); + }); + + it("clear session on close event", async () => { + const onCbSpy = jest.spyOn(SmoochLib, "on"); + const chatCoreZendesk = provideChatCoreZendesk(mockConfig); + await chatCoreZendesk.init(mockMessageResponse()); + expect(chatCoreZendesk.getSession()).toBeDefined(); - const dummyFn = jest.fn(); - chatCoreZendesk.on("typing", dummyFn); - expect(onTypingSpy).toBeCalled(); - - // get "typing:start" callback - const onStartTypingFn = onTypingSpy.mock.calls[1][1] as any; - // simulate a typing event - onStartTypingFn(); - expect(dummyFn).toBeCalledWith(true); - - // get "typing:stop" callback - const onStopTypingFn = onTypingSpy.mock.calls[2][1] as any; - // simulate a typing event - onStopTypingFn(); - expect(dummyFn).toBeCalledWith(false); + // get the parameter passed to the onSpy callback + const text = "hello world!"; + const conversation = { conversation: { id: mockConversationId } }; + const dummyFn = jest.fn(); + chatCoreZendesk.on("close", dummyFn); + const onCbFn = onCbSpy.mock.calls[0][1] as any; + + // simulate a session close event via a message event from internal bot + onCbFn( + { text, type: "text", role: "business", subroles: ["AI"] }, + conversation + ); + expect(dummyFn).toBeCalledWith(conversation); + expect(chatCoreZendesk.getSession()).toBeUndefined(); + }); }); -it("clear session on close event", async () => { - const onCbSpy = jest.spyOn(SmoochLib, "on"); +it("returns session on getSession", async () => { const chatCoreZendesk = provideChatCoreZendesk(mockConfig); await chatCoreZendesk.init(mockMessageResponse()); - expect(chatCoreZendesk.getSession()).toBeDefined(); - - // get the parameter passed to the onSpy callback - const text = "hello world!"; - const conversation = { conversation: { id: mockConversationId } }; - const dummyFn = jest.fn(); - chatCoreZendesk.on("close", dummyFn); - const onCbFn = onCbSpy.mock.calls[0][1] as any; - - // simulate a session close event via a message event from internal bot - onCbFn( - { text, type: "text", role: "business", subroles: ["AI"] }, - conversation - ); - expect(dummyFn).toBeCalledWith(conversation); - expect(chatCoreZendesk.getSession()).toBeUndefined(); + expect(chatCoreZendesk.getSession()).toBe(mockConversationId); }); it("clears session on resetSession", async () => {