From a2d86e4edead6d22e99ae85f320267a0f5208033 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 20 Feb 2026 15:48:16 +0000 Subject: [PATCH 1/2] Remove unused endpoints --- ...acity.ts => adjustChatCapacity.private.ts} | 32 +- functions/assignOfflineContact.ts | 261 ----------- functions/autopilotRedirect.protected.ts | 95 ---- functions/checkTaskAssignment.ts | 84 ---- functions/completeTaskAssignment.ts | 147 ------ functions/createContactlessTask.ts | 86 ---- functions/getTaskAndReservations.ts | 132 ------ functions/pullTask.ts | 2 +- .../adjustCapacityListener.private.ts | 2 +- functions/transferChatStart.ts | 2 +- tests/adjustChatCapacity.test.ts | 152 ++---- tests/assignOfflineContact.test.ts | 435 ------------------ tests/createContactlessTask.test.ts | 158 ------- 13 files changed, 48 insertions(+), 1540 deletions(-) rename functions/{adjustChatCapacity.ts => adjustChatCapacity.private.ts} (83%) delete mode 100644 functions/assignOfflineContact.ts delete mode 100644 functions/autopilotRedirect.protected.ts delete mode 100644 functions/checkTaskAssignment.ts delete mode 100644 functions/completeTaskAssignment.ts delete mode 100644 functions/createContactlessTask.ts delete mode 100644 functions/getTaskAndReservations.ts delete mode 100644 tests/assignOfflineContact.test.ts delete mode 100644 tests/createContactlessTask.test.ts diff --git a/functions/adjustChatCapacity.ts b/functions/adjustChatCapacity.private.ts similarity index 83% rename from functions/adjustChatCapacity.ts rename to functions/adjustChatCapacity.private.ts index bd255a53..bb6473ae 100644 --- a/functions/adjustChatCapacity.ts +++ b/functions/adjustChatCapacity.private.ts @@ -15,15 +15,7 @@ */ import '@twilio-labs/serverless-runtime-types'; -import { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { - responseWithCors, - bindResolve, - error400, - error500, - send, - functionValidator as TokenValidator, -} from '@tech-matters/serverless-helpers'; +import { Context } from '@twilio-labs/serverless-runtime-types/types'; import { WorkerChannelInstance } from 'twilio/lib/rest/taskrouter/v1/workspace/worker/workerChannel'; type EnvVars = { @@ -136,25 +128,3 @@ export const adjustChatCapacity = async ( }; export type AdjustChatCapacityType = typeof adjustChatCapacity; - -export const handler = TokenValidator( - async (context: Context, event: Body, callback: ServerlessCallback) => { - const response = responseWithCors(); - const resolve = bindResolve(callback)(response); - - const { workerSid, adjustment } = event; - - try { - if (workerSid === undefined) return resolve(error400('workerSid')); - if (adjustment === undefined) return resolve(error400('adjustment')); - - const validBody = { workerSid, adjustment }; - - const { status, message } = await adjustChatCapacity(context, validBody); - - return resolve(send(status)({ message, status })); - } catch (err: any) { - return resolve(error500(err)); - } - }, -); diff --git a/functions/assignOfflineContact.ts b/functions/assignOfflineContact.ts deleted file mode 100644 index 3fec39c3..00000000 --- a/functions/assignOfflineContact.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * 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 { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { - responseWithCors, - bindResolve, - error400, - error500, - success, - send, - functionValidator as TokenValidator, -} from '@tech-matters/serverless-helpers'; - -type EnvVars = { - TWILIO_WORKSPACE_SID: string; - TWILIO_CHAT_TRANSFER_WORKFLOW_SID: string; -}; - -export type Body = { - targetSid?: string; - finalTaskAttributes: string; - request: { cookies: {}; headers: {} }; -}; - -// eslint-disable-next-line prettier/prettier -type TaskInstance = Awaited['taskrouter']['workspaces']>['tasks']>['fetch']>>; -// eslint-disable-next-line prettier/prettier -type WorkerInstance = Awaited['taskrouter']['workspaces']>['workers']>['fetch']>>; - -type AssignmentResult = - | { - type: 'error'; - payload: { status: number; message: string; taskRemoved: boolean; attributes?: string }; - } - | { type: 'success'; newTask: TaskInstance }; - -const wait = (ms: number): Promise => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -const cleanUpTask = async (task: TaskInstance, message: string) => { - const { attributes } = task; - const taskRemoved = await task.remove(); - - return { - type: 'error', - payload: { - status: 500, - message, - taskRemoved, - attributes, - }, - } as const; -}; - -const assignToAvailableWorker = async ( - event: Body, - newTask: TaskInstance, - retry: number = 0, -): Promise => { - const reservations = await newTask.reservations().list(); - const reservation = reservations.find((r) => r.workerSid === event.targetSid); - - if (!reservation) { - if (retry < 8) { - await wait(200); - return assignToAvailableWorker(event, newTask, retry + 1); - } - - return cleanUpTask(newTask, 'Error: reservation for task not created.'); - } - - const accepted = await reservation.update({ reservationStatus: 'accepted' }); - - if (accepted.reservationStatus !== 'accepted') { - return cleanUpTask(newTask, 'Error: reservation for task not accepted.'); - } - - const completed = await reservation.update({ reservationStatus: 'completed' }); - - if (completed.reservationStatus !== 'completed') { - return cleanUpTask(newTask, 'Error: reservation for task not completed.'); - } - - // eslint-disable-next-line no-console - if (retry) console.warn(`Needed ${retry} retries to get reservation`); - - return { type: 'success', newTask } as const; -}; - -const assignToOfflineWorker = async ( - context: Context, - event: Body, - targetWorker: WorkerInstance, - newTask: TaskInstance, -) => { - const previousActivity = targetWorker.activitySid; - const previousAttributes = JSON.parse(targetWorker.attributes); - - const availableActivity = await context - .getTwilioClient() - .taskrouter.workspaces(context.TWILIO_WORKSPACE_SID) - .activities.list({ available: 'true' }); - - if (availableActivity.length > 1) { - // eslint-disable-next-line no-console - console.warn( - `There are ${availableActivity.length} available worker activities, but there should only be one.`, - ); - } - - await targetWorker.update({ - activitySid: availableActivity[0].sid, - attributes: JSON.stringify({ ...previousAttributes, waitingOfflineContact: true }), // waitingOfflineContact is used to avoid other tasks to be assigned during this window of time (workflow rules) - }); - - const result = await assignToAvailableWorker(event, newTask); - - await targetWorker.update({ - activitySid: previousActivity, - attributes: JSON.stringify(previousAttributes), - rejectPendingReservations: true, - }); - - return result; -}; - -const assignOfflineContact = async ( - context: Context, - body: Required, -): Promise => { - const client = context.getTwilioClient(); - const { targetSid, finalTaskAttributes } = body; - - const targetWorker = await client.taskrouter - .workspaces(context.TWILIO_WORKSPACE_SID) - .workers(targetSid) - .fetch(); - - const targetWorkerAttributes = JSON.parse(targetWorker.attributes); - - if (targetWorkerAttributes.helpline === undefined) { - return { - type: 'error', - payload: { - status: 500, - message: - 'Error: the worker does not have helpline attribute set, check the worker configuration.', - taskRemoved: false, - }, - }; - } - - if (targetWorkerAttributes.waitingOfflineContact) { - return { - type: 'error', - payload: { - status: 500, - message: 'Error: the worker is already waiting for an offline contact.', - taskRemoved: false, - }, - }; - } - - const queueRequiredTaskAttributes = { - helpline: targetWorkerAttributes.helpline, - channelType: 'default', - isContactlessTask: true, - isInMyBehalf: true, - }; - - // create New task - const newTask = await client.taskrouter.workspaces(context.TWILIO_WORKSPACE_SID).tasks.create({ - workflowSid: context.TWILIO_CHAT_TRANSFER_WORKFLOW_SID, - taskChannel: 'default', - attributes: JSON.stringify(queueRequiredTaskAttributes), - priority: 100, - }); - - const newTaskAttributes = JSON.parse(newTask.attributes); - const parsedFinalAttributes = JSON.parse(finalTaskAttributes); - const routingAttributes = { - targetSid, - transferTargetType: 'worker', - helpline: targetWorkerAttributes.helpline, - channelType: 'default', - isContactlessTask: true, - isInMyBehalf: true, - }; - - const mergedAttributes = { - ...newTaskAttributes, - ...parsedFinalAttributes, - ...routingAttributes, - customers: { - ...parsedFinalAttributes.customers, - external_id: newTask.sid, - }, - }; - - const updatedTask = await newTask.update({ attributes: JSON.stringify(mergedAttributes) }); - - if (targetWorker.available) { - // assign the task, accept and complete it - return assignToAvailableWorker(body, updatedTask); - } - // Set the worker available, assign the task, accept, complete it and set worker to previous state - return assignToOfflineWorker(context, body, targetWorker, updatedTask); -}; - -export const handler = TokenValidator( - async (context: Context, event: Body, callback: ServerlessCallback) => { - const response = responseWithCors(); - const resolve = bindResolve(callback)(response); - - try { - const { targetSid, finalTaskAttributes } = event; - - if (targetSid === undefined) { - resolve(error400('targetSid')); - return; - } - if (finalTaskAttributes === undefined) { - resolve(error400('finalTaskAttributes')); - return; - } - - const assignmentResult = await assignOfflineContact(context, { - targetSid, - finalTaskAttributes, - request: { cookies: {}, headers: {} }, - }); - - if (assignmentResult.type === 'error') { - const { payload } = assignmentResult; - resolve(send(payload.status)(payload)); - return; - } - - resolve(success(assignmentResult.newTask)); - } catch (err: any) { - resolve(error500(err)); - } - }, -); diff --git a/functions/autopilotRedirect.protected.ts b/functions/autopilotRedirect.protected.ts deleted file mode 100644 index d952149c..00000000 --- a/functions/autopilotRedirect.protected.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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 { - Context, - ServerlessCallback, - ServerlessFunctionSignature, -} from '@twilio-labs/serverless-runtime-types/types'; - -export interface Event { - Channel: string; - CurrentTask: string; - Memory: string; - UserIdentifier: string; - request: { cookies: {}; headers: {} }; -} - -type EnvVars = {}; - -const handleChatChannel = async (context: Context, event: Event) => { - const memory = JSON.parse(event.Memory); - const { ServiceSid, ChannelSid } = memory.twilio.chat; - - const channel = await context - .getTwilioClient() - .chat.services(ServiceSid) - .channels(ChannelSid) - .fetch(); - - const attributes = JSON.parse(channel.attributes); - - // if channel is webchat, disable the input - if (attributes.channel_type === 'web') { - const user = await context - .getTwilioClient() - .chat.services(ServiceSid) - .users(event.UserIdentifier) - .fetch(); - - const userAttr = JSON.parse(user.attributes); - const updatedAttr = { ...userAttr, lockInput: true }; - - await user.update({ attributes: JSON.stringify(updatedAttr) }); - } -}; - -const buildActionsArray = (context: Context, event: Event) => { - const memory = JSON.parse(event.Memory); - - switch (memory.at) { - case 'survey': { - const redirect = { redirect: 'task://counselor_handoff' }; - return [redirect]; - } - default: { - // If we ever get here, it's in error - // Just handoff to counselor for now, maybe need to internally record an error - const redirect = { redirect: 'task://counselor_handoff' }; - return [redirect]; - } - } -}; - -export const handler: ServerlessFunctionSignature = async ( - context: Context, - event: Event, - callback: ServerlessCallback, -) => { - try { - if (event.Channel === 'chat' && event.CurrentTask === 'redirect_function') { - await handleChatChannel(context, event); - } - - const actions = buildActionsArray(context, event); - const returnObj = { actions }; - - callback(null, returnObj); - } catch (err: any) { - // If something goes wrong, just handoff to counselor so contact is not lost - callback(null, { actions: [{ redirect: 'task://counselor_handoff' }] }); - } -}; diff --git a/functions/checkTaskAssignment.ts b/functions/checkTaskAssignment.ts deleted file mode 100644 index fd42c11d..00000000 --- a/functions/checkTaskAssignment.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * 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 { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { - responseWithCors, - bindResolve, - error400, - error500, - success, - functionValidator as TokenValidator, -} from '@tech-matters/serverless-helpers'; - -type EnvVars = { - TWILIO_WORKSPACE_SID: string; -}; - -export type Body = { - request: { cookies: {}; headers: {} }; -} & { taskSid: string }; - -type ContactType = { - taskSid: string; -}; - -const isTaskAssigned = async ( - context: Context, - event: Required>, -): Promise => { - const client = context.getTwilioClient(); - - try { - const task = await client.taskrouter - .workspaces(context.TWILIO_WORKSPACE_SID) - .tasks(event.taskSid) - .fetch(); - - const { assignmentStatus } = task; - - return assignmentStatus === 'assigned' || assignmentStatus === 'wrapping'; - } catch (err) { - console.error('Error fetching task:', err); - return false; - } -}; - -export const handler = TokenValidator( - async (context: Context, event: Body, callback: ServerlessCallback) => { - const response = responseWithCors(); - const resolve = bindResolve(callback)(response); - - console.log('event', event); - - try { - const { taskSid } = event; - - if (taskSid === undefined) { - resolve(error400('taskSid')); - return; - } - - const result = await isTaskAssigned(context, { - taskSid, - }); - - resolve(success({ isAssigned: result })); - } catch (err: any) { - resolve(error500(err)); - } - }, -); diff --git a/functions/completeTaskAssignment.ts b/functions/completeTaskAssignment.ts deleted file mode 100644 index 15b45d9c..00000000 --- a/functions/completeTaskAssignment.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Close task as a supervisor -/** - * 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 { validator } from 'twilio-flex-token-validator'; -import { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { - responseWithCors, - bindResolve, - error400, - error500, - success, - functionValidator as TokenValidator, - error403, -} from '@tech-matters/serverless-helpers'; - -type EnvVars = { - TWILIO_WORKSPACE_SID: string; - ACCOUNT_SID: string; - AUTH_TOKEN: string; -}; - -type TaskInstance = Awaited< - ReturnType< - ReturnType< - ReturnType['taskrouter']['workspaces']>['tasks'] - >['fetch'] - > ->; - -type ContactComplete = { - action: 'complete'; - taskSid: string; - targetSid: string; - finalTaskAttributes: TaskInstance['attributes']; -}; - -export type Body = { - request: { cookies: {}; headers: {} }; - Token?: string; -} & ContactComplete; - -type AssignmentResult = - | { - type: 'error'; - payload: { message: string; attributes?: string }; - } - | { type: 'success'; completedTask: TaskInstance }; - -const closeTaskAssignment = async ( - context: Context, - event: Required>, -): Promise => { - const client = context.getTwilioClient(); - - try { - const task = await client.taskrouter - .workspaces(context.TWILIO_WORKSPACE_SID) - .tasks(event.taskSid) - .fetch(); - const attributes = JSON.parse(task.attributes); - const callSid = attributes?.call_sid; - - // Ends the task for the worker and client for chat tasks, and only for the worker for voice tasks - const completedTask = await task.update({ - assignmentStatus: 'completed', - attributes: event.finalTaskAttributes, - }); - - // Ends the call for the client for voice - if (callSid) await client.calls(callSid).update({ status: 'completed' }); - - return { type: 'success', completedTask } as const; - } catch (err) { - return { - type: 'error', - payload: { message: String(err) }, - }; - } -}; - -export type TokenValidatorResponse = { worker_sid?: string; roles?: string[] }; - -const isSupervisor = (tokenResult: TokenValidatorResponse) => - Array.isArray(tokenResult.roles) && tokenResult.roles.includes('supervisor'); - -export const handler = TokenValidator( - async (context: Context, event: Body, callback: ServerlessCallback) => { - const response = responseWithCors(); - const resolve = bindResolve(callback)(response); - - const accountSid = context.ACCOUNT_SID; - const authToken = context.AUTH_TOKEN; - const token = event.Token; - - if (!token) { - resolve(error400('token')); - return; - } - - try { - const tokenResult: TokenValidatorResponse = await validator( - token as string, - accountSid, - authToken, - ); - - const isSupervisorToken = isSupervisor(tokenResult); - - if (!isSupervisorToken) { - resolve( - error403(`Unauthorized: endpoint not open to non supervisors. ${isSupervisorToken}`), - ); - return; - } - - const { taskSid } = event; - - if (taskSid === undefined) { - resolve(error400('taskSid is undefined')); - return; - } - - const result = await closeTaskAssignment(context, { - taskSid, - finalTaskAttributes: JSON.stringify({}), - }); - - resolve(success(result)); - } catch (err: any) { - resolve(error500(err)); - } - }, -); diff --git a/functions/createContactlessTask.ts b/functions/createContactlessTask.ts deleted file mode 100644 index af3a9eea..00000000 --- a/functions/createContactlessTask.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 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 { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { - responseWithCors, - bindResolve, - error400, - error500, - success, - functionValidator as TokenValidator, -} from '@tech-matters/serverless-helpers'; - -type EnvVars = { - TWILIO_WORKSPACE_SID: string; - TWILIO_CHAT_TRANSFER_WORKFLOW_SID: string; -}; - -export type Body = { - targetSid?: string; - transferTargetType?: string; - helpline?: string; - request: { cookies: {}; headers: {} }; -}; - -export const handler = TokenValidator( - async (context: Context, event: Body, callback: ServerlessCallback) => { - const client = context.getTwilioClient(); - - const response = responseWithCors(); - const resolve = bindResolve(callback)(response); - - const { targetSid, transferTargetType, helpline } = event; - - try { - if (targetSid === undefined) { - resolve(error400('targetSid')); - return; - } - if (transferTargetType === undefined) { - resolve(error400('transferTargetType')); - return; - } - if (helpline === undefined) { - resolve(error400('helpline')); - return; - } - - const newAttributes = { - targetSid, - transferTargetType, - helpline, - channelType: 'default', - isContactlessTask: true, - }; - - // create New task - const newTask = await client.taskrouter - .workspaces(context.TWILIO_WORKSPACE_SID) - .tasks.create({ - workflowSid: context.TWILIO_CHAT_TRANSFER_WORKFLOW_SID, - taskChannel: 'default', - attributes: JSON.stringify(newAttributes), - priority: 100, - }); - - resolve(success(newTask)); - } catch (err: any) { - resolve(error500(err)); - } - }, -); diff --git a/functions/getTaskAndReservations.ts b/functions/getTaskAndReservations.ts deleted file mode 100644 index aa7ea75f..00000000 --- a/functions/getTaskAndReservations.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Get task as a supervisor -/** - * 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 { validator } from 'twilio-flex-token-validator'; -import { Context, ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { - responseWithCors, - bindResolve, - error400, - error500, - success, - functionValidator as TokenValidator, - error403, - send, -} from '@tech-matters/serverless-helpers'; - -type EnvVars = { - TWILIO_WORKSPACE_SID: string; - ACCOUNT_SID: string; - AUTH_TOKEN: string; -}; - -type TaskInstance = Awaited< - ReturnType< - ReturnType< - ReturnType['taskrouter']['workspaces']>['tasks'] - >['fetch'] - > ->; - -type ContactComplete = { - action: 'complete'; - taskSid: string; - targetSid: string; - finalTaskAttributes: TaskInstance['attributes']; -}; - -export type Body = { - request: { cookies: {}; headers: {} }; - Token?: string; -} & ContactComplete; - -export type TokenValidatorResponse = { worker_sid?: string; roles?: string[] }; - -const getReservations = async (context: Context, taskSid: string) => { - try { - const reservations = await context - .getTwilioClient() - .taskrouter.workspaces(context.TWILIO_WORKSPACE_SID) - .tasks(taskSid) - .reservations.list(); - - if (reservations.length === 0) { - console.info(`No reservations found for task ${taskSid}`); - } - - return reservations; - } catch (err) { - console.error('Failed to fetch reservations:', err); - return undefined; - } -}; - -export const handler = TokenValidator( - async (context: Context, event: Body, callback: ServerlessCallback) => { - const response = responseWithCors(); - const resolve = bindResolve(callback)(response); - - const { ACCOUNT_SID: accountSid, AUTH_TOKEN: authToken } = context; - const { Token: token, taskSid } = event; - - if (!token) { - return resolve(error400('token')); - } - - try { - const tokenResult: TokenValidatorResponse = await validator( - token as string, - accountSid, - authToken, - ); - const isSupervisorToken = - Array.isArray(tokenResult.roles) && tokenResult.roles.includes('supervisor'); - - if (!isSupervisorToken) { - return resolve(error403('Unauthorized: endpoint not open to non supervisors.')); - } - - if (taskSid === undefined) { - return resolve(error400('taskSid is undefined')); - } - - const reservations = await getReservations(context, taskSid); - - try { - const task = await context - .getTwilioClient() - .taskrouter.workspaces(context.TWILIO_WORKSPACE_SID) - .tasks(taskSid) - .fetch(); - - return resolve(success({ task, reservations })); - } catch (err: any) { - const error = err as Error; - if ( - error.message.match( - /The requested resource \/Workspaces\/WS[a-z0-9]+\/Tasks\/WT[a-z0-9]+ was not found/, - ) - ) { - return resolve(send(404)({ message: error.message, status: 404 })); - } - return resolve(error500(error)); - } - } catch (err) { - return resolve(error500(err as Error)); - } - }, -); diff --git a/functions/pullTask.ts b/functions/pullTask.ts index 7dc450f2..89c3b8a2 100644 --- a/functions/pullTask.ts +++ b/functions/pullTask.ts @@ -23,7 +23,7 @@ import { send, success, } from '@tech-matters/serverless-helpers'; -import { AdjustChatCapacityType } from './adjustChatCapacity'; +import { AdjustChatCapacityType } from './adjustChatCapacity.private'; type EnvVars = { TWILIO_WORKSPACE_SID: string; diff --git a/functions/taskrouterListeners/adjustCapacityListener.private.ts b/functions/taskrouterListeners/adjustCapacityListener.private.ts index 301f4ea4..605e5969 100644 --- a/functions/taskrouterListeners/adjustCapacityListener.private.ts +++ b/functions/taskrouterListeners/adjustCapacityListener.private.ts @@ -22,7 +22,7 @@ import { EventFields, TaskrouterListener, } from '@tech-matters/serverless-helpers/taskrouter'; -import type { AdjustChatCapacityType } from '../adjustChatCapacity'; +import type { AdjustChatCapacityType } from '../adjustChatCapacity.private'; export const eventTypes: EventType[] = [RESERVATION_ACCEPTED, RESERVATION_REJECTED]; diff --git a/functions/transferChatStart.ts b/functions/transferChatStart.ts index 60060a7e..d8e1e68b 100644 --- a/functions/transferChatStart.ts +++ b/functions/transferChatStart.ts @@ -27,7 +27,7 @@ import { success, functionValidator as TokenValidator, } from '@tech-matters/serverless-helpers'; -import type { AdjustChatCapacityType } from './adjustChatCapacity'; +import type { AdjustChatCapacityType } from './adjustChatCapacity.private'; type EnvVars = { TWILIO_WORKSPACE_SID: string; diff --git a/tests/adjustChatCapacity.test.ts b/tests/adjustChatCapacity.test.ts index 0bf3924e..f16533cd 100644 --- a/tests/adjustChatCapacity.test.ts +++ b/tests/adjustChatCapacity.test.ts @@ -13,17 +13,17 @@ * 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 { adjustChatCapacity, Body as HandlerBody } from '../functions/adjustChatCapacity.private'; -import { ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { handler as adjustChatCapacity, Body } from '../functions/adjustChatCapacity'; - -import helpers, { MockedResponse } from './helpers'; +import helpers from './helpers'; jest.mock('@tech-matters/serverless-helpers', () => ({ ...jest.requireActual('@tech-matters/serverless-helpers'), functionValidator: (handlerFn: any) => handlerFn, })); +type Body = Required>; + const runTestSuite = (maxMessageCapacity: number | string) => { let workerChannel = { taskChannelUniqueName: 'chat', @@ -95,55 +95,36 @@ const runTestSuite = (maxMessageCapacity: number | string) => { helpers.teardown(); }); - test('Should return status 400', async () => { + test('Should throw with incomplete data', async () => { const workerSid = 'worker123'; // const adjustment = 'increase'; - const event1 = { request: { cookies: {}, headers: {} } }; - const event2 = { ...event1, workerSid }; - - const events = [event1, event2]; - - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(400); - }; - - await Promise.all(events.map((e) => adjustChatCapacity(baseContext, e, callback))); + const event1 = {}; + const event2 = { workerSid }; + await expect(adjustChatCapacity(baseContext, event1 as Body)).rejects.toThrow(); + await expect(adjustChatCapacity(baseContext, event2 as Body)).resolves.toStrictEqual({ + status: 400, + message: 'Invalid adjustment argument', + }); }); - test('Should return status 500', async () => { + test("Should throw if worker doesn't exist", async () => { const event: Body = { workerSid: 'non-existing', adjustment: 'increase', - request: { cookies: {}, headers: {} }, - }; - - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(500); - expect(response.getBody().message).toContain('Non existing worker'); }; - await adjustChatCapacity(baseContext, event, callback); + await expect(adjustChatCapacity(baseContext, event)).rejects.toThrow(); }); test('Should return status 200 (increase)', async () => { const event: Body = { workerSid: 'worker123', adjustment: 'increase', - request: { cookies: {}, headers: {} }, - }; - - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(200); - expect(workerChannel.configuredCapacity).toStrictEqual(2); }; - await adjustChatCapacity(baseContext, event, callback); + const { status } = await adjustChatCapacity(baseContext, event); + expect(status).toBe(200); + expect(workerChannel.configuredCapacity).toStrictEqual(2); }); test('Should return status 200 (effectively decrease)', async () => { @@ -153,17 +134,11 @@ const runTestSuite = (maxMessageCapacity: number | string) => { const event: Body = { workerSid: 'worker123', adjustment: 'decrease', - request: { cookies: {}, headers: {} }, }; - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(200); - expect(workerChannel.configuredCapacity).toStrictEqual(1); - }; - - await adjustChatCapacity(baseContext, event, callback); + const { status } = await adjustChatCapacity(baseContext, event); + expect(status).toBe(200); + expect(workerChannel.configuredCapacity).toStrictEqual(1); }); test('Should return status 200 (do nothing instead of decrease)', async () => { @@ -173,17 +148,11 @@ const runTestSuite = (maxMessageCapacity: number | string) => { const event: Body = { workerSid: 'worker123', adjustment: 'decrease', - request: { cookies: {}, headers: {} }, }; - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(200); - expect(workerChannel.configuredCapacity).toStrictEqual(1); - }; - - await adjustChatCapacity(baseContext, event, callback); + const { status } = await adjustChatCapacity(baseContext, event); + expect(status).toBe(200); + expect(workerChannel.configuredCapacity).toStrictEqual(1); }); test('Should return status 412 (Still have available capacity, no need to increase)', async () => { @@ -193,19 +162,11 @@ const runTestSuite = (maxMessageCapacity: number | string) => { const event: Body = { workerSid: 'worker123', adjustment: 'increase', - request: { cookies: {}, headers: {} }, }; - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(412); - expect(response.getBody().message).toContain( - 'Still have available capacity, no need to increase', - ); - }; - - await adjustChatCapacity(baseContext, event, callback); + const { status, message } = await adjustChatCapacity(baseContext, event); + expect(status).toBe(412); + expect(message).toContain('Still have available capacity, no need to increase'); }); test('Should return status 412 (Reached the max capacity)', async () => { @@ -215,20 +176,14 @@ const runTestSuite = (maxMessageCapacity: number | string) => { const event: Body = { workerSid: 'worker123', adjustment: 'increase', - request: { cookies: {}, headers: {} }, }; - await adjustChatCapacity(baseContext, event, () => {}); + await adjustChatCapacity(baseContext, event); expect(workerChannel.configuredCapacity).toStrictEqual(2); - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(412); - expect(response.getBody().message).toContain('Reached the max capacity'); - }; - - await adjustChatCapacity(baseContext, event, callback); + const { status, message } = await adjustChatCapacity(baseContext, event); + expect(status).toBe(412); + expect(message).toContain('Reached the max capacity'); }); test('Should return status 404 (Could not find worker)', async () => { @@ -238,19 +193,13 @@ const runTestSuite = (maxMessageCapacity: number | string) => { const event: Body = { workerSid: 'nonExisting', adjustment: 'increase', - request: { cookies: {}, headers: {} }, }; - await adjustChatCapacity(baseContext, event, () => {}); + await adjustChatCapacity(baseContext, event); - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(404); - expect(response.getBody().message).toContain('Could not find worker'); - }; - - await adjustChatCapacity(baseContext, event, callback); + const { status, message } = await adjustChatCapacity(baseContext, event); + expect(status).toBe(404); + expect(message).toContain('Could not find worker'); }); test('Should return status 404 (Could not find chat channel)', async () => { @@ -259,42 +208,29 @@ const runTestSuite = (maxMessageCapacity: number | string) => { const event: Body = { workerSid: 'withoutChannel', adjustment: 'increase', - request: { cookies: {}, headers: {} }, }; - - await adjustChatCapacity(baseContext, event, () => {}); + await adjustChatCapacity(baseContext, event); expect(workerChannel.configuredCapacity).toStrictEqual(2); - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(404); - expect(response.getBody().message).toContain('Could not find chat channel'); - }; - - await adjustChatCapacity(baseContext, event, callback); + const { status, message } = await adjustChatCapacity(baseContext, event); + expect(status).toBe(404); + expect(message).toContain('Could not find chat channel'); }); test('Should return status 409 (Worker does not have a "maxMessageCapacity" attribute, can\'t adjust capacity.)', async () => { const event: Body = { workerSid: 'withoutAttr', adjustment: 'increase', - request: { cookies: {}, headers: {} }, }; - await adjustChatCapacity(baseContext, event, () => {}); + await adjustChatCapacity(baseContext, event); expect(workerChannel.configuredCapacity).toStrictEqual(2); - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(409); - expect(response.getBody().message).toContain( - `Worker ${event.workerSid} does not have a "maxMessageCapacity" attribute, can't adjust capacity.`, - ); - }; - - await adjustChatCapacity(baseContext, event, callback); + const { status, message } = await adjustChatCapacity(baseContext, event); + expect(status).toBe(409); + expect(message).toContain( + `Worker ${event.workerSid} does not have a "maxMessageCapacity" attribute, can't adjust capacity.`, + ); }); }); }; diff --git a/tests/assignOfflineContact.test.ts b/tests/assignOfflineContact.test.ts deleted file mode 100644 index f12d6f7a..00000000 --- a/tests/assignOfflineContact.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * 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 { ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import each from 'jest-each'; -import { handler as assignOfflineContact, Body } from '../functions/assignOfflineContact'; - -import helpers, { MockedResponse } from './helpers'; - -jest.mock('@tech-matters/serverless-helpers', () => ({ - ...jest.requireActual('@tech-matters/serverless-helpers'), - functionValidator: (handlerFn: any) => handlerFn, -})); - -let tasks: any[] = []; - -const createReservation = (taskSid: string, workerSid: string) => { - const task = tasks.find((t) => t.sid === taskSid); - task.reservationsSource = [ - { - workerSid, - reservationStatus: 'pending', - update: async ({ reservationStatus }: { reservationStatus: string }) => { - const reservation = (await task.reservations().list()).find( - (r: any) => r.workerSid === workerSid, - ); - - if ( - reservationStatus === 'accepted' && - reservation.reservationStatus === 'pending' && - (workerSid === 'available-worker-with-accepted' || - workerSid === 'not-available-worker-with-accepted') - ) { - const accepted = { ...reservation, reservationStatus }; - task.reservationsSource = [accepted]; - return accepted; - } - - if ( - reservationStatus === 'accepted' && - reservation.reservationStatus === 'pending' && - (workerSid === 'available-worker-with-completed' || - workerSid === 'not-available-worker-with-completed') - ) { - const accepted = { ...reservation, reservationStatus }; - task.reservationsSource = [accepted]; - return accepted; - } - - if ( - reservationStatus === 'completed' && - reservation.reservationStatus === 'accepted' && - (workerSid === 'available-worker-with-completed' || - workerSid === 'not-available-worker-with-completed') - ) { - const completed = { ...reservation, reservationStatus }; - task.reservationsSource = [completed]; - return completed; - } - - return reservation; - }, - }, - ]; - task.reservations = () => ({ - list: async () => task.reservationsSource, - }); -}; - -const createTask = (sid: string, options: any) => ({ - sid, - ...options, - reservations: () => ({ - list: async () => [], - }), - update: async ({ attributes }: { attributes: any }) => { - tasks = tasks.map((t) => (t.sid === sid ? { ...t, attributes } : t)); - - const hasReservation = - ( - await tasks - .find((t) => t.sid === sid) - .reservations() - .list() - ).length > 0; - - const { targetSid } = JSON.parse(attributes); - if ( - [ - 'available-worker-with-reservation', - 'not-available-worker-with-reservation', - 'available-worker-with-accepted', - 'not-available-worker-with-accepted', - 'available-worker-with-completed', - 'not-available-worker-with-completed', - ].includes(targetSid) && - !hasReservation - ) { - createReservation(sid, targetSid); - } - - const task = tasks.find((t) => t.sid === sid); - return task; - }, - remove: async () => { - tasks = tasks.filter((t) => t.sid === sid); - }, -}); - -const updateWorkerMock = jest.fn(); - -let workspaces: { [x: string]: any } = {}; - -const baseContext = { - getTwilioClient: (): any => ({ - taskrouter: { - workspaces: (workspaceSID: string) => { - if (workspaces[workspaceSID]) return workspaces[workspaceSID]; - - throw new Error('Workspace does not exists'); - }, - }, - }), - DOMAIN_NAME: 'serverless', - TWILIO_WORKSPACE_SID: 'WSxxx', - TWILIO_CHAT_TRANSFER_WORKFLOW_SID: 'WWxxx', - PATH: 'PATH', - SERVICE_SID: undefined, - ENVIRONMENT_SID: undefined, -}; - -beforeAll(() => { - helpers.setup({}); -}); -afterAll(() => { - helpers.teardown(); -}); - -beforeEach(() => { - workspaces = { - WSxxx: { - activities: { - list: async () => [ - { - sid: 'Available', - friendlyName: 'Available', - available: 'true', - }, - ], - }, - tasks: { - create: async (options: any) => { - const newTask = createTask(Math.random().toString(), options); - tasks = [...tasks, newTask]; - return tasks.find((t) => t.sid === newTask.sid); - }, - }, - workers: (workerSid: string) => ({ - fetch: () => { - if (workerSid === 'noHelpline-worker') { - return { - attributes: JSON.stringify({}), - sid: 'waitingOfflineContact-worker', - available: true, - }; - } - - if (workerSid === 'waitingOfflineContact-worker') { - return { - attributes: JSON.stringify({ waitingOfflineContact: true, helpline: 'helpline' }), - sid: 'waitingOfflineContact-worker', - available: true, - }; - } - - if (workerSid === 'available-worker-no-reservation') { - return { - attributes: JSON.stringify({ waitingOfflineContact: false, helpline: 'helpline' }), - sid: 'available-worker-no-reservation', - available: true, - }; - } - - if (workerSid === 'not-available-worker-no-reservation') { - return { - attributes: JSON.stringify({ waitingOfflineContact: false, helpline: 'helpline' }), - activitySid: 'activitySid', - sid: 'not-available-worker-with-reservation', - available: false, - update: updateWorkerMock, - }; - } - - if (workerSid === 'available-worker-with-reservation') { - return { - attributes: JSON.stringify({ waitingOfflineContact: false, helpline: 'helpline' }), - sid: 'available-worker-with-reservation', - available: true, - update: updateWorkerMock, - }; - } - - if (workerSid === 'not-available-worker-with-reservation') { - return { - attributes: JSON.stringify({ waitingOfflineContact: false, helpline: 'helpline' }), - activitySid: 'activitySid', - sid: 'not-available-worker-with-reservation', - available: false, - update: updateWorkerMock, - }; - } - - if (workerSid === 'available-worker-with-accepted') { - return { - attributes: JSON.stringify({ waitingOfflineContact: false, helpline: 'helpline' }), - sid: 'available-worker-with-accepted', - available: true, - update: updateWorkerMock, - }; - } - - if (workerSid === 'not-available-worker-with-accepted') { - return { - attributes: JSON.stringify({ waitingOfflineContact: false, helpline: 'helpline' }), - activitySid: 'activitySid', - sid: 'not-available-worker-with-accepted', - available: false, - update: updateWorkerMock, - }; - } - - if (workerSid === 'available-worker-with-completed') { - return { - attributes: JSON.stringify({ waitingOfflineContact: false, helpline: 'helpline' }), - sid: 'available-worker-with-completed', - available: true, - update: updateWorkerMock, - }; - } - - if (workerSid === 'not-available-worker-with-completed') { - return { - attributes: JSON.stringify({ waitingOfflineContact: false, helpline: 'helpline' }), - activitySid: 'activitySid', - sid: 'not-available-worker-with-completed', - available: false, - update: updateWorkerMock, - }; - } - - throw new Error('Non existing worker'); - }, - }), - }, - }; -}); - -afterEach(() => { - tasks = []; - updateWorkerMock.mockClear(); -}); - -describe('assignOfflineContact', () => { - test('Should return status 400', async () => { - const bad1: Body = { - targetSid: undefined, - finalTaskAttributes: JSON.stringify({}), - request: { cookies: {}, headers: {} }, - }; - const bad2: Body = { - targetSid: 'WKxxx', - // @ts-ignore - finalTaskAttributes: undefined, - }; - - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(400); - }; - - await Promise.all( - [bad1, bad2].map((event) => assignOfflineContact(baseContext, event, callback)), - ); - }); - - each([ - { - condition: 'task creation throws an error', - targetSid: 'available-worker-with-completed', - expectedMessage: 'Intentionally thrown error', - taskCreateMethod: () => { - throw new Error('Intentionally thrown error'); - }, - }, - { - condition: 'workspace does not exist', - targetSid: 'WKxxx', - expectedMessage: 'Workspace does not exists', - context: { - getTwilioClient: baseContext.getTwilioClient, - DOMAIN_NAME: baseContext.DOMAIN_NAME, - }, - }, - { - condition: 'worker does not exist', - targetSid: 'non-existing-worker', - expectedMessage: 'Non existing worker', - }, - { - condition: 'worker has no helpline', - targetSid: 'noHelpline-worker', - expectedMessage: - 'Error: the worker does not have helpline attribute set, check the worker configuration.', - }, - { - condition: 'worker has waitingOfflineContact set', - targetSid: 'waitingOfflineContact-worker', - expectedMessage: 'Error: the worker is already waiting for an offline contact.', - }, - { - condition: 'worker is available with no reservation', - targetSid: 'available-worker-no-reservation', - expectedMessage: 'Error: reservation for task not created.', - }, - { - condition: 'worker is not available with no reservation', - targetSid: 'not-available-worker-no-reservation', - expectedMessage: 'Error: reservation for task not created.', - expectedUpdatedWorkerMockCalls: 2, - }, - { - condition: 'worker is available with reservation', - targetSid: 'available-worker-with-reservation', - expectedMessage: 'Error: reservation for task not accepted.', - }, - { - condition: 'worker is not available with a reservation', - targetSid: 'not-available-worker-with-reservation', - expectedMessage: 'Error: reservation for task not accepted.', - expectedUpdatedWorkerMockCalls: 2, - }, - { - condition: 'worker is available and accepted', - targetSid: 'available-worker-with-accepted', - expectedMessage: 'Error: reservation for task not completed.', - }, - { - condition: 'worker is not available and accepted', - targetSid: 'not-available-worker-with-accepted', - expectedMessage: 'Error: reservation for task not completed.', - expectedUpdatedWorkerMockCalls: 2, - }, - ]).test( - "Should return status 500 '$expectedMessage' when $condition", - async ({ - targetSid, - expectedMessage, - expectedUpdatedWorkerMockCalls = 0, - context = baseContext, - taskCreateMethod, - }) => { - // Patch task create method if a custom one is set - workspaces.WSxxx.tasks.create = taskCreateMethod ?? workspaces.WSxxx.tasks.create; - - const event: Body = { - targetSid, - finalTaskAttributes: JSON.stringify({}), - request: { cookies: {}, headers: {} }, - }; - let response: MockedResponse | undefined; - - const callback: ServerlessCallback = (err, result) => { - response = result; - }; - - updateWorkerMock.mockClear(); - await assignOfflineContact(context, event, callback); - - expect(response).toBeDefined(); - if (response) { - expect(response.getStatus()).toBe(500); - expect(response.getBody().message).toContain(expectedMessage); - } - expect(updateWorkerMock).toBeCalledTimes(expectedUpdatedWorkerMockCalls); - }, - ); - - test('Should return status 200 (available worker)', async () => { - const event: Body = { - targetSid: 'available-worker-with-completed', - finalTaskAttributes: JSON.stringify({}), - request: { cookies: {}, headers: {} }, - }; - - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(200); - expect(updateWorkerMock).not.toBeCalled(); - }; - - await assignOfflineContact(baseContext, event, callback); - }); - - test('Should return status 200 (not available worker)', async () => { - const event: Body = { - targetSid: 'not-available-worker-with-completed', - finalTaskAttributes: JSON.stringify({}), - request: { cookies: {}, headers: {} }, - }; - - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(200); - expect(updateWorkerMock).toBeCalledTimes(2); - }; - - await assignOfflineContact(baseContext, event, callback); - }); -}); diff --git a/tests/createContactlessTask.test.ts b/tests/createContactlessTask.test.ts deleted file mode 100644 index fd580697..00000000 --- a/tests/createContactlessTask.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * 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 { ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { handler as createContactlessTask, Body } from '../functions/createContactlessTask'; - -import helpers, { MockedResponse } from './helpers'; - -jest.mock('@tech-matters/serverless-helpers', () => ({ - ...jest.requireActual('@tech-matters/serverless-helpers'), - functionValidator: (handlerFn: any) => handlerFn, -})); - -let tasks: any[] = []; - -const workspaces: { [x: string]: any } = { - WSxxx: { - tasks: { - create: async (options: any) => { - if (JSON.parse(options.attributes).helpline === 'intentionallyThrow') { - throw new Error('Intentionally thrown error'); - } - - tasks = [...tasks, { sid: Math.random(), ...options }]; - }, - }, - }, -}; - -const baseContext = { - getTwilioClient: (): any => ({ - taskrouter: { - workspaces: (workspaceSID: string) => { - if (workspaces[workspaceSID]) return workspaces[workspaceSID]; - - throw new Error('Workspace does not exists'); - }, - }, - }), - DOMAIN_NAME: 'serverless', - TWILIO_WORKSPACE_SID: 'WSxxx', - TWILIO_CHAT_TRANSFER_WORKFLOW_SID: 'WWxxx', - PATH: 'PATH', - SERVICE_SID: undefined, - ENVIRONMENT_SID: undefined, -}; - -beforeAll(() => { - helpers.setup({}); -}); -afterAll(() => { - helpers.teardown(); -}); - -afterEach(() => { - tasks = []; -}); - -describe('createContactlessTask', () => { - test('Should return status 400', async () => { - const bad1: Body = { - targetSid: undefined, - transferTargetType: 'worker', - helpline: 'helpline', - request: { cookies: {}, headers: {} }, - }; - const bad2: Body = { - targetSid: 'WKxxx', - transferTargetType: undefined, - helpline: 'helpline', - request: { cookies: {}, headers: {} }, - }; - const bad3: Body = { - targetSid: 'WKxxx', - transferTargetType: 'worker', - helpline: undefined, - request: { cookies: {}, headers: {} }, - }; - - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(400); - }; - - await Promise.all( - [bad1, bad2, bad3].map((event) => createContactlessTask(baseContext, event, callback)), - ); - }); - - test('Should return status 500', async () => { - const event1: Body = { - targetSid: 'WKxxx', - transferTargetType: 'worker', - helpline: 'helpline', - request: { cookies: {}, headers: {} }, - }; - - const event2: Body = { - targetSid: 'WKxxx', - transferTargetType: 'worker', - helpline: 'intentionallyThrow', - request: { cookies: {}, headers: {} }, - }; - - const callback1: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(500); - expect(response.getBody().message).toContain('Workspace does not exists'); - }; - - const callback2: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(500); - expect(response.getBody().message).toContain('Intentionally thrown error'); - }; - - const { getTwilioClient, DOMAIN_NAME } = baseContext; - const payload: any = { getTwilioClient, DOMAIN_NAME }; - await createContactlessTask(payload, event1, callback1); - await createContactlessTask(baseContext, event2, callback2); - }); - - test('Should return status 200 (WARM)', async () => { - const event: Body = { - targetSid: 'WKxxx', - transferTargetType: 'worker', - helpline: 'helpline', - request: { cookies: {}, headers: {} }, - }; - const beforeTasks = Array.from(tasks); - - const callback: ServerlessCallback = (err, result) => { - expect(result).toBeDefined(); - const response = result as MockedResponse; - expect(response.getStatus()).toBe(200); - expect(beforeTasks).toHaveLength(0); - expect(tasks).toHaveLength(1); - }; - - await createContactlessTask(baseContext, event, callback); - }); -}); From c7dacf46c776e9a892da50533d33266f1ad5a495 Mon Sep 17 00:00:00 2001 From: Stephen Hand Date: Fri, 20 Feb 2026 16:08:39 +0000 Subject: [PATCH 2/2] Remove old tests --- tests/autopilotRedirect.test.ts | 188 -------------------------------- 1 file changed, 188 deletions(-) delete mode 100644 tests/autopilotRedirect.test.ts diff --git a/tests/autopilotRedirect.test.ts b/tests/autopilotRedirect.test.ts deleted file mode 100644 index ad276fc1..00000000 --- a/tests/autopilotRedirect.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * 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 { ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types'; -import { handler as autopilotRedirect, Event } from '../functions/autopilotRedirect.protected'; - -import helpers from './helpers'; - -const users: { [u: string]: any } = { - user: { - attributes: '{}', - update: async (attributes: string) => { - users.user = attributes; - }, - }, -}; - -const baseContext = { - getTwilioClient: (): any => ({ - chat: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - services: (serviceSid: string) => ({ - channels: (channelSid: string) => { - if (channelSid === 'web') { - return { - fetch: async () => ({ - attributes: '{"channel_type": "web"}', - }), - }; - } - - if (channelSid === 'failure') throw new Error('Something crashed'); - - return { - fetch: async () => ({ - attributes: '{}', - }), - }; - }, - users: (user: string) => ({ - fetch: async () => users[user], - }), - }), - }, - }), - DOMAIN_NAME: 'serverless', - PATH: 'PATH', - SERVICE_SID: undefined, - ENVIRONMENT_SID: undefined, -}; - -describe('Redirect forwards to the correct task', () => { - beforeAll(() => { - helpers.setup({}); - }); - afterAll(() => { - helpers.teardown(); - }); - - test('Should forward normal surveys to a counselor (NO update to user as channel is not web)', async () => { - const event: Event = { - Channel: 'chat', - CurrentTask: 'redirect_function', - UserIdentifier: 'user', - Memory: `{ - "twilio": { - "chat": { "ChannelSid": "not web" }, - "collected_data": { - "collect_survey": { - "answers": { - "about_self": { - "answer": "Yes" - }, - "age": { - "answer": "12" - }, - "gender": { - "answer": "Girl" - } - } - } - } - }, - "at": "survey" - }`, - request: { cookies: {}, headers: {} }, - }; - - const callback: ServerlessCallback = (err, result) => { - const expectedAttr = {}; - expect(result).toMatchObject({ actions: [{ redirect: 'task://counselor_handoff' }] }); - expect(err).toBeNull(); - expect(users.user.attributes).toBe(JSON.stringify(expectedAttr)); - }; - - await autopilotRedirect(baseContext, event, callback); - }); - - test('Should forward normal surveys to a counselor (YES update to user as channel is web)', async () => { - const event: Event = { - Channel: 'chat', - CurrentTask: 'redirect_function', - UserIdentifier: 'user', - Memory: `{ - "twilio": { - "chat": { "ChannelSid": "web" }, - "collected_data": { - "collect_survey": { - "answers": { - "about_self": { - "answer": "Yes" - }, - "age": { - "answer": "12" - }, - "gender": { - "answer": "Girl" - } - } - } - } - }, - "at": "survey" - }`, - request: { cookies: {}, headers: {} }, - }; - - const callback: ServerlessCallback = (err, result) => { - const expectedAttr = { lockInput: true }; - - expect(result).toMatchObject({ actions: [{ redirect: 'task://counselor_handoff' }] }); - expect(err).toBeNull(); - expect(users.user.attributes).toBe(JSON.stringify(expectedAttr)); - }; - - await autopilotRedirect(baseContext, event, callback); - }); - - test('Should forward handoff to counselor if something fails', async () => { - const event: Event = { - Channel: 'chat', - CurrentTask: 'redirect_function', - UserIdentifier: 'user', - Memory: `{ - "twilio": { - "chat": { "ChannelSid": "failure" }, - "collected_data": { - "collect_survey": { - "answers": { - "about_self": { - "answer": "Yes" - }, - "age": { - "answer": "12" - }, - "gender": { - "answer": "Girl" - } - } - } - } - }, - "at": "survey" - }`, - request: { cookies: {}, headers: {} }, - }; - - const callback: ServerlessCallback = (err, result) => { - expect(result).toMatchObject({ actions: [{ redirect: 'task://counselor_handoff' }] }); - expect(err).toBeNull(); - }; - - await autopilotRedirect(baseContext, event, callback); - }); -});