diff --git a/functions/captureChannelWithBot.protected.ts b/functions/captureChannelWithBot.protected.ts index 3a96efcf..00ce288b 100644 --- a/functions/captureChannelWithBot.protected.ts +++ b/functions/captureChannelWithBot.protected.ts @@ -37,7 +37,7 @@ type EnvVars = { SURVEY_WORKFLOW_SID: string; }; -type Body = { +export type Body = { channelSid: string; // (in Studio Flow, flow.channel.address) The channel to capture message: string; // (in Studio Flow, trigger.message.Body) The triggering message fromServiceUser: string; // (in Studio Flow, trigger.message.From) The service user unique name @@ -141,7 +141,7 @@ export const handler = async ( }); const handlerPath = Runtime.getFunctions()['helpers/lexClient'].path; - const lexClient = require(handlerPath).addCustomerExternalId as LexClient; + const lexClient = require(handlerPath) as LexClient; const lexResponse = await lexClient.postText(context, { botName: updatedChannelAttributes.channelCapturedByBot.botName, diff --git a/functions/webhooks/chatbotCallback.protected.ts b/functions/webhooks/chatbotCallback.protected.ts index 2f578ddd..d707903a 100644 --- a/functions/webhooks/chatbotCallback.protected.ts +++ b/functions/webhooks/chatbotCallback.protected.ts @@ -34,6 +34,7 @@ type EnvVars = { ASELO_APP_ACCESS_KEY: string; ASELO_APP_SECRET_KEY: string; AWS_REGION: string; + TWILIO_WORKSPACE_SID: string; }; export type Body = Partial & {}; @@ -78,7 +79,7 @@ export const handler = async ( // Send message to bot only if it's from child if (EventType === 'onMessageSent' && channelAttributes.fromServiceUser === From) { const handlerPath = Runtime.getFunctions()['helpers/lexClient'].path; - const lexClient = require(handlerPath).addCustomerExternalId as LexClient; + const lexClient = require(handlerPath) as LexClient; const lexResponse = await lexClient.postText(context, { botName: channelAttributes.channelCapturedByBot.botName, @@ -124,7 +125,7 @@ export const handler = async ( }), // Move control task to complete state client.taskrouter.v1 - .workspaces('WORKFLOW_SID') + .workspaces(context.TWILIO_WORKSPACE_SID) .tasks(channelAttributes.controlTaskSid) .update({ assignmentStatus: 'completed' }), // Remove this webhook from the channel diff --git a/tests/captureChannelWithBot.test.ts b/tests/captureChannelWithBot.test.ts new file mode 100644 index 00000000..65047ac9 --- /dev/null +++ b/tests/captureChannelWithBot.test.ts @@ -0,0 +1,232 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ +import '@twilio-labs/serverless-runtime-types'; +import { + handler as captureChannelWithBot, + Body, +} from '../functions/captureChannelWithBot.protected'; +import helpers from './helpers'; +import { LexClient } from '../functions/helpers/lexClient.private'; + +// eslint-disable-next-line global-require +const lexClient = require('../functions/helpers/lexClient.private') as LexClient; + +jest.mock('../functions/helpers/lexClient.private', () => ({ + postText: jest.fn(), +})); + +const fetch = jest.fn().mockReturnValue({ + attributes: JSON.stringify({ + channelCapturedByBot: { + botId: 'C6HUSTIFBR', + botAliasId: 'TSTALIASID', + localeId: 'en_US', + }, + }), + webhooks: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({}), + }), + update: jest.fn().mockReturnValue({ + attributes: JSON.stringify({ + channelCapturedByBot: { + botName: 'C6HUSTIFBR', + botAlias: 'TSTALIASID', + localeId: 'en_US', + }, + }), + }), + messages: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({ + body: 'lexResponse', + from: 'Bot', + xTwilioWebhookEnabled: 'true', + }), + }), +}); + +const mockContext = { + getTwilioClient: jest.fn().mockImplementation(() => ({ + chat: { + v2: { + services: jest.fn().mockReturnValue({ + channels: jest.fn().mockReturnValue({ + fetch, + }), + }), + }, + services: jest.fn().mockReturnValue({ + channels: jest.fn().mockReturnValue({ + webhooks: { + list: jest.fn().mockReturnValue([]), + }, + }), + }), + }, + taskrouter: { + workspaces: jest.fn().mockReturnValue({ + tasks: { + create: jest.fn().mockReturnValue({}), + }, + }), + }, + })), + DOMAIN_NAME: 'domain.com', + PATH: 'string', + SERVICE_SID: 'string', + ENVIRONMENT_SID: 'string', + CHAT_SERVICE_SID: 'Ws2xxxxxx', + ASELO_APP_ACCESS_KEY: 'AW12xx2', + ASELO_APP_SECRET_KEY: 'KA23xxx09i', + AWS_REGION: 'us-east-1', + TWILIO_WORKSPACE_SID: 'WE23xxx0orre', + SURVEY_WORKFLOW_SID: 'AZexxx903esd', +}; + +const mockEvent: Body = { + channelSid: 'SID123xxx09sa', + message: 'Message sent', + fromServiceUser: 'Test User', + studioFlowSid: 'FL0123xxdew', + botName: 'C6HUSTIFBR', +}; + +const mockCallback = jest.fn(); +const lexResponse = { message: 'Lex response message' }; + +beforeAll(() => { + const runtime = new helpers.MockRuntime(mockContext); + // eslint-disable-next-line no-underscore-dangle + runtime._addFunction('captureChannelWithBot', 'functions/captureChannelWithBot.protected'); + helpers.setup({}, runtime); +}); + +beforeEach(() => { + const functions = { + 'helpers/lexClient': { + path: '../functions/helpers/lexClient.private.ts', + }, + }; + + const getFunctionsMock = jest.fn().mockReturnValue(functions); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + global.Runtime.getFunctions = () => getFunctionsMock(); + + lexClient.postText = jest.fn().mockResolvedValue(lexResponse); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('captureChannelWithBot', () => { + test('should return lexResonse, update channel, and resolve with succes', async () => { + const event: Body = { + channelSid: 'SID123xxx09sa', + message: 'Message sent', + fromServiceUser: 'Test User', + studioFlowSid: 'FL0123xxdew', + botName: 'C6HUSTIFBR', + }; + await captureChannelWithBot(mockContext, event, mockCallback); + + expect(mockCallback).toHaveBeenCalledWith( + null, + expect.objectContaining({ + _body: 'Channel captured by bot =)', + _statusCode: 200, + }), + ); + }); + // We need to ignore the typescript error since channelSid is required. + // Same apply to others + + test('should resolve with error message when channelSid is missing', async () => { + const event = { ...mockEvent, channelSid: undefined }; + + // @ts-ignore + await captureChannelWithBot(mockContext, event, mockCallback); + + expect(lexClient.postText).not.toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toBeNull(); + expect(mockCallback.mock.calls[0][1]).toEqual( + expect.objectContaining({ + _body: expect.objectContaining({ + message: 'Error: channelSid parameter not provided', + status: 400, + }), + _statusCode: 400, + }), + ); + }); + + test('should resolve with error message when message is missing', async () => { + const event = { ...mockEvent, message: undefined }; + + // @ts-ignore + await captureChannelWithBot(mockContext, event, mockCallback); + + expect(lexClient.postText).not.toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toBeNull(); + expect(mockCallback.mock.calls[0][1]).toEqual( + expect.objectContaining({ + _body: expect.objectContaining({ + message: 'Error: message parameter not provided', + status: 400, + }), + _statusCode: 400, + }), + ); + }); + + test('should resolve with error message when fromServiceUser is missing', async () => { + const event = { ...mockEvent, fromServiceUser: undefined }; + + // @ts-ignore + await captureChannelWithBot(mockContext, event, mockCallback); + + expect(lexClient.postText).not.toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toBeNull(); + expect(mockCallback.mock.calls[0][1]).toEqual( + expect.objectContaining({ + _body: expect.objectContaining({ + message: 'Error: fromServiceUser parameter not provided', + status: 400, + }), + _statusCode: 400, + }), + ); + }); + + test('should resolve with error message when studioFlowSid is missing', async () => { + const event = { ...mockEvent, studioFlowSid: undefined }; + + // @ts-ignore + await captureChannelWithBot(mockContext, event, mockCallback); + + expect(lexClient.postText).not.toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toBeNull(); + expect(mockCallback.mock.calls[0][1]).toEqual( + expect.objectContaining({ + _body: expect.objectContaining({ + message: 'Error: studioFlowSid parameter not provided', + status: 400, + }), + _statusCode: 400, + }), + ); + }); +}); diff --git a/tests/webhooks/chatbotCallback.test.ts b/tests/webhooks/chatbotCallback.test.ts new file mode 100644 index 00000000..2c4fea47 --- /dev/null +++ b/tests/webhooks/chatbotCallback.test.ts @@ -0,0 +1,237 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { + handler as chatbotCallback, + Body, +} from '../../functions/webhooks/chatbotCallback.protected'; +import helpers from '../helpers'; +import { LexClient } from '../../functions/helpers/lexClient.private'; + +// eslint-disable-next-line global-require +const lexClient = require('../../functions/helpers/lexClient.private') as LexClient; + +jest.mock('../../functions/helpers/lexClient.private', () => ({ + postText: jest.fn(), + isEndOfDialog: jest.fn(), + deleteSession: jest.fn(), +})); + +const context = { + getTwilioClient: jest.fn().mockReturnValue({ + chat: { + services: jest.fn().mockReturnValue({ + channels: jest.fn().mockReturnValue({ + fetch: jest.fn().mockResolvedValue({ + attributes: JSON.stringify({ + channelSid: 'SID123xxx09sa', + message: 'Message sent', + fromServiceUser: 'channelAttributes', + studioFlowSid: 'FL0123xxdew', + botName: 'C6HUSTIFBR', + channelCapturedByBot: { + botName: 'C6HUSTIFBR', + botAlias: 'TSTALIASID', + studioFlowSid: 'FL0123xxdew', + localeId: 'en_US', + }, + }), + messages: jest.fn().mockReturnValue({ + create: jest.fn().mockReturnValue({ + body: 'lexResponse', + from: 'Bot', + xTwilioWebhookEnabled: 'true', + }), + }), + update: jest.fn().mockReturnValue({ + attributes: JSON.stringify({ + channelCapturedByBot: { + botName: 'C6HUSTIFBR', + botAlias: 'TSTALIASID', + localeId: 'en_US', + }, + }), + }), + webhooks: jest.fn().mockReturnValue({ + get: jest.fn().mockReturnValue({ + remove: jest.fn().mockReturnValue({}), + }), + }), + }), + messages: jest.fn().mockReturnValue({ + create: jest.fn().mockResolvedValue({}), + }), + }), + }), + }, + studio: { + v2: { + flows: jest.fn().mockReturnValue({ + executions: { + create: jest.fn().mockResolvedValue({}), + }, + }), + }, + }, + taskrouter: { + v1: { + workspaces: jest.fn().mockReturnValue({ + tasks: jest.fn().mockReturnValue({ + update: jest.fn().mockResolvedValue({}), + }), + }), + }, + }, + }), + + DOMAIN_NAME: 'string', + PATH: 'string', + SERVICE_SID: 'string', + ENVIRONMENT_SID: 'string', + CHAT_SERVICE_SID: 'Ws2xxxxxx', + ASELO_APP_ACCESS_KEY: 'AW12xx2', + ASELO_APP_SECRET_KEY: 'KA23xxx09i', + AWS_REGION: 'us-east-1', + TWILIO_WORKSPACE_SID: 'Waer3xxx98', +}; + +const mockCallback = jest.fn(); +const lexResponse = { + message: 'Lex response message', + dialogState: 'dialogState response state', + deleteSession: {}, +}; + +beforeAll(() => { + const runtime = new helpers.MockRuntime(context); + // eslint-disable-next-line no-underscore-dangle + runtime._addFunction('webhooks/chatbotCallback', 'functions/webhooks/chatbotCallback.protected'); + helpers.setup({}, runtime); +}); + +beforeEach(() => { + const functions = { + 'helpers/lexClient': { + path: '../../functions/helpers/lexClient.private.ts', + }, + }; + + const getFunctionsMock = jest.fn().mockReturnValue(functions); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + global.Runtime.getFunctions = () => getFunctionsMock(); + + lexClient.postText = jest.fn().mockResolvedValue(lexResponse); + lexClient.isEndOfDialog = jest.fn().mockResolvedValue(lexResponse); + lexClient.deleteSession = jest.fn().mockResolvedValue(lexResponse); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('chatbotCallback', () => { + test('should return lexResonse, update channel, and resolve with succes', async () => { + const event: Body = { + Body: 'Test body', + From: 'channelAttributes', + ChannelSid: 'Test channelSid', + EventType: 'onMessageSent', + }; + + await chatbotCallback(context, event, mockCallback); + + // Assert that the necessary functions were called with the correct arguments + expect(context.getTwilioClient).toHaveBeenCalled(); + expect(context.getTwilioClient().chat.services).toHaveBeenCalledWith(context.CHAT_SERVICE_SID); + expect(context.getTwilioClient().chat.services().channels).toHaveBeenCalledWith( + event.ChannelSid, + ); + expect(context.getTwilioClient().chat.services().channels().fetch).toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toBeNull(); + expect(mockCallback.mock.calls[0][1]).toEqual( + expect.objectContaining({ + _body: 'All messages sent :)', + _statusCode: 200, + }), + ); + }); + + test('should handle the event and ignore it', async () => { + const event: Body = { + Body: 'Test body', + From: 'Test from', + ChannelSid: 'WA23xxx0ie', + EventType: 'onMessageSent', + }; + + await chatbotCallback(context, event, mockCallback); + + expect(lexClient.postText).not.toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toBeNull(); + expect(mockCallback.mock.calls[0][1]).toEqual( + expect.objectContaining({ + _body: 'Event ignored', + _statusCode: 200, + }), + ); + }); + + test('should resolve with error message when event is empty', async () => { + const event = {}; + + await chatbotCallback(context, event, mockCallback); + + // Assert that the necessary functions were called with the correct arguments + expect(lexClient.postText).not.toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toBeNull(); + expect(mockCallback.mock.calls[0][1]).toEqual( + expect.objectContaining({ + _body: expect.objectContaining({ + message: 'Error: Body parameter not provided', + status: 400, + }), + _statusCode: 400, + }), + ); + }); + + test('should handle errors', async () => { + const event: Body = { + Body: 'Test body', + From: 'Test from', + ChannelSid: 'Test channelSid', + EventType: 'onMessageSent', + }; + + const error = new Error('Test error'); + context.getTwilioClient().chat.services().channels().fetch.mockRejectedValue(error); + + await chatbotCallback(context, event, mockCallback); + + // Assert that the necessary functions were called with the correct arguments + expect(lexClient.postText).not.toHaveBeenCalled(); + expect(mockCallback.mock.calls[0][0]).toBeNull(); + expect(mockCallback.mock.calls[0][1]).toEqual( + expect.objectContaining({ + _body: expect.objectContaining({ + message: 'Test error', + }), + _statusCode: 500, + }), + ); + }); +});