diff --git a/docs/samples/contact-center/app.js b/docs/samples/contact-center/app.js index 1ee0d9551b1..05c04cceaa3 100644 --- a/docs/samples/contact-center/app.js +++ b/docs/samples/contact-center/app.js @@ -1,15 +1,3 @@ -/* eslint-disable no-underscore-dangle */ -/* eslint-env browser */ - -/* global Webex */ - -/* eslint-disable require-jsdoc */ -/* eslint-disable no-unused-vars */ -/* eslint-disable no-console */ -/* eslint-disable no-global-assign */ -/* eslint-disable no-multi-assign */ -/* eslint-disable max-len */ - // Globals let webex; let sdk; @@ -21,6 +9,7 @@ let agentId; let taskControl; let task; let taskId; +let wrapupCodes = []; // Add this to store wrapup codes const authTypeElm = document.querySelector('#auth-type'); const credentialsFormElm = document.querySelector('#credentials'); @@ -42,6 +31,12 @@ const incomingDetailsElm = document.querySelector('#incoming-call'); const answerElm = document.querySelector('#answer'); const declineElm = document.querySelector('#decline'); const callControlListener = document.querySelector('#callcontrolsection'); +const holdResumeElm = document.querySelector('#hold-resume'); +const pauseResumeRecordingElm = document.querySelector('#pause-resume-recording'); +const endElm = document.querySelector('#end'); +const wrapupElm = document.querySelector('#wrapup'); +const wrapupCodesDropdownElm = document.querySelector('#wrapupCodesDropdown'); +const autoResumeCheckboxElm = document.querySelector('#auto-resume-checkbox'); // Add this // Store and Grab `access-token` from sessionStorage if (sessionStorage.getItem('date') > new Date().getTime()) { @@ -84,14 +79,32 @@ const taskEvents = new CustomEvent('task:incoming', { }, }); -// TODO: Activate the call control buttons once the call is accepted and refctor this +function updateButtonsPostEndCall() { + holdResumeElm.disabled = true; + endElm.disabled = true; + pauseResumeRecordingElm.disabled = true; + wrapupElm.disabled = false; + wrapupCodesDropdownElm.disabled = false; +} + function registerTaskListeners(task) { task.on('task:assigned', (task) => { - console.log('Call has been accepted for task: ', task.data.interactionId); - }) + console.info('Call has been accepted for task: ', task.data.interactionId); + holdResumeElm.disabled = false; + holdResumeElm.innerText = 'Hold'; + pauseResumeRecordingElm.disabled = false; + pauseResumeRecordingElm.innerText = 'Pause Recording'; + endElm.disabled = false; + }); task.on('task:media', (track) => { document.getElementById('remote-audio').srcObject = new MediaStream([track]); - }) + }); + task.on('task:end', (task) => { + if (!endElm.disabled) { + console.info('Call ended successfully by the external user'); + updateButtonsPostEndCall(); + } + }); } function generateWebexConfig({credentials}) { @@ -146,6 +159,8 @@ function register() { teamsDropdown.innerHTML = ''; // Clear previously selected option on teamsDropdown const listTeams = agentProfile.teams; agentId = agentProfile.agentId; + wrapupCodes = agentProfile.wrapupCodes; + populateWrapupCodesDropdown(); listTeams.forEach((team) => { const option = document.createElement('option'); option.value = team.id; @@ -166,6 +181,7 @@ function register() { }); if (agentProfile.isAgentLoggedIn) { + loginAgentElm.disabled = true; logoutAgentElm.classList.remove('hidden'); } @@ -189,7 +205,17 @@ function register() { taskEvents.detail.task = task; incomingCallListener.dispatchEvent(taskEvents); - }) + }) +} + +function populateWrapupCodesDropdown() { + wrapupCodesDropdownElm.innerHTML = ''; // Clear previous options + wrapupCodes.forEach((code) => { + const option = document.createElement('option'); + option.text = code.name; + option.value = code.id; + wrapupCodesDropdownElm.add(option); + }); } async function handleAgentLogin(e) { @@ -366,3 +392,79 @@ function expandAll() { }); } +function holdResumeCall() { + if (holdResumeElm.innerText === 'Hold') { + holdResumeElm.disabled = true; + task.hold().then(() => { + console.info('Call held successfully'); + holdResumeElm.innerText = 'Resume'; + holdResumeElm.disabled = false; + }).catch((error) => { + console.error('Failed to hold the call', error); + holdResumeElm.disabled = false; + }); + } else { + holdResumeElm.disabled = true; + task.resume().then(() => { + console.info('Call resumed successfully'); + holdResumeElm.innerText = 'Hold'; + holdResumeElm.disabled = false; + }).catch((error) => { + console.error('Failed to resume the call', error); + holdResumeElm.disabled = false; + }); + } +} + +function togglePauseResumeRecording() { + const autoResumed = autoResumeCheckboxElm.checked; + if (pauseResumeRecordingElm.innerText === 'Pause Recording') { + pauseResumeRecordingElm.disabled = true; + task.pauseRecording().then(() => { + console.info('Recording paused successfully'); + pauseResumeRecordingElm.innerText = 'Resume Recording'; + pauseResumeRecordingElm.disabled = false; + autoResumeCheckboxElm.disabled = false; + }).catch((error) => { + console.error('Failed to pause recording', error); + pauseResumeRecordingElm.disabled = false; + }); + } else { + pauseResumeRecordingElm.disabled = true; + task.resumeRecording({ autoResumed: autoResumed }).then(() => { + console.info('Recording resumed successfully'); + pauseResumeRecordingElm.innerText = 'Pause Recording'; + pauseResumeRecordingElm.disabled = false; + autoResumeCheckboxElm.disabled = true; + }).catch((error) => { + console.error('Failed to resume recording', error); + pauseResumeRecordingElm.disabled = false; + }); + } +} + +function endCall() { + endElm.disabled = true; + task.end().then(() => { + console.log('Call ended successfully by agent'); + updateButtonsPostEndCall(); + }).catch((error) => { + console.error('Failed to end the call', error); + endElm.disabled = false; + }); +} + +function wrapupCall() { + wrapupElm.disabled = true; + const wrapupReason = wrapupCodesDropdownElm.options[wrapupCodesDropdownElm.selectedIndex].text; + const auxCodeId = wrapupCodesDropdownElm.options[wrapupCodesDropdownElm.selectedIndex].value; + task.wrapup({wrapUpReason: wrapupReason, auxCodeId: auxCodeId}).then(() => { + console.info('Call wrapped up successfully'); + holdResumeElm.disabled = true; + endElm.disabled = true; + wrapupCodesDropdownElm.disabled = true; + }).catch((error) => { + console.error('Failed to wrap up the call', error); + wrapupElm.disabled = false; + }); +} \ No newline at end of file diff --git a/docs/samples/contact-center/index.html b/docs/samples/contact-center/index.html index 0d99a068d8f..e106e4fb140 100644 --- a/docs/samples/contact-center/index.html +++ b/docs/samples/contact-center/index.html @@ -150,7 +150,24 @@

Call Controls
- + + +
+ Call Wrapup + + +
+
+ Recording Controls +
+ + +
+
diff --git a/packages/@webex/plugin-cc/src/cc.ts b/packages/@webex/plugin-cc/src/cc.ts index 9ba322eeedf..11baf533edd 100644 --- a/packages/@webex/plugin-cc/src/cc.ts +++ b/packages/@webex/plugin-cc/src/cc.ts @@ -347,9 +347,9 @@ export default class ContactCenter extends WebexPlugin implements IContactCenter } catch (error) { const {reason, error: detailedError} = getErrorDetails(error, 'silentReLogin', CC_FILE); if (reason === 'AGENT_NOT_FOUND') { - LoggerProxy.info('Agent not found during re-login, handling silently', { + LoggerProxy.log('Agent not found during re-login, handling silently', { module: CC_FILE, - method: this.silentRelogin.name, + method: 'silentRelogin', }); return; diff --git a/packages/@webex/plugin-cc/src/constants.ts b/packages/@webex/plugin-cc/src/constants.ts index 744b898e6cb..8f63fd52671 100644 --- a/packages/@webex/plugin-cc/src/constants.ts +++ b/packages/@webex/plugin-cc/src/constants.ts @@ -10,3 +10,4 @@ export const CC_FILE = 'cc'; export const CONNECTION_SERVICE_FILE = 'connection-service'; export const WEB_SOCKET_MANAGER_FILE = 'WebSocketManager'; export const AQM_REQS_FILE = 'aqm-reqs'; +export const TASK_MANAGER_FILE = 'TaskManager'; diff --git a/packages/@webex/plugin-cc/src/services/core/Utils.ts b/packages/@webex/plugin-cc/src/services/core/Utils.ts index 907f543f4f2..163be2b9ea8 100644 --- a/packages/@webex/plugin-cc/src/services/core/Utils.ts +++ b/packages/@webex/plugin-cc/src/services/core/Utils.ts @@ -13,10 +13,12 @@ const getCommonErrorDetails = (errObj: WebexRequestPayload) => { export const getErrorDetails = (error: any, methodName: string, moduleName: string) => { const failure = error.details as Failure; const reason = failure?.data?.reason ?? `Error while performing ${methodName}`; - LoggerProxy.error(`${methodName} failed with trackingId: ${failure?.trackingId}`, { - module: moduleName, - method: methodName, - }); + if (!(reason === 'AGENT_NOT_FOUND' && methodName === 'silentReLogin')) { + LoggerProxy.error(`${methodName} failed with trackingId: ${failure?.trackingId}`, { + module: moduleName, + method: methodName, + }); + } return { error: new Error(reason ?? `Error while performing ${methodName}`), diff --git a/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts b/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts index 76bf780fb52..0b175c42a3e 100644 --- a/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts +++ b/packages/@webex/plugin-cc/src/services/core/aqm-reqs.ts @@ -98,12 +98,12 @@ export default class AqmReqs { if ('errId' in notifFail) { LoggerProxy.log(`Routing request failed: ${msg}`, { module: AQM_REQS_FILE, - method: this.createPromise.name, + method: 'createPromise', }); const eerr = new Err.Details(notifFail.errId, msg as any); LoggerProxy.log(`Routing request failed: ${eerr}`, { module: AQM_REQS_FILE, - method: this.createPromise.name, + method: 'createPromise', }); reject(eerr); } else { diff --git a/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts b/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts index f222d81071e..f9e42edba80 100644 --- a/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts +++ b/packages/@webex/plugin-cc/src/services/core/websocket/connection-service.ts @@ -53,9 +53,9 @@ export class ConnectionService extends EventEmitter { isKeepAlive: this.isKeepAlive, }; this.webSocketManager.handleConnectionLost(event); - LoggerProxy.log(`Dispatching connection lost event`, { + LoggerProxy.log(`Dispatching connection event`, { module: CONNECTION_SERVICE_FILE, - method: this.dispatchConnectionEvent.name, + method: 'dispatchConnectionEvent', }); this.emit('connectionLost', event); } @@ -119,7 +119,7 @@ export class ConnectionService extends EventEmitter { private handleSocketClose = async (): Promise => { LoggerProxy.info(`event=socketConnectionRetry | Trying to reconnect to websocket`, { module: CONNECTION_SERVICE_FILE, - method: this.handleSocketClose.name, + method: 'handleSocketClose', }); const onlineStatus = navigator.onLine; if (onlineStatus) { diff --git a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts index 085b56c9bd1..deb89ea9290 100644 --- a/packages/@webex/plugin-cc/src/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/src/services/task/TaskManager.ts @@ -4,8 +4,10 @@ import {WebSocketManager} from '../core/websocket/WebSocketManager'; import routingContact from './contact'; import WebCallingService from '../WebCallingService'; import {ITask, TASK_EVENTS, TaskId} from './types'; +import {TASK_MANAGER_FILE} from '../../constants'; import {CC_EVENTS} from '../config/types'; import {LoginOption} from '../../types'; +import LoggerProxy from '../../logger-proxy'; import Task from '.'; export default class TaskManager extends EventEmitter { @@ -68,7 +70,7 @@ export default class TaskManager extends EventEmitter { break; case CC_EVENTS.AGENT_CONTACT_ASSIGNED: this.currentTask = this.currentTask.updateTaskData(payload.data); - this.emit(TASK_EVENTS.TASK_ASSIGNED, this.currentTask); + this.currentTask.emit(TASK_EVENTS.TASK_ASSIGNED, this.currentTask); break; case CC_EVENTS.CONTACT_ENDED: this.emit(TASK_EVENTS.TASK_UNASSIGNED, this.currentTask); @@ -77,6 +79,13 @@ export default class TaskManager extends EventEmitter { this.webCallingService.unregisterCallListeners(); } break; + case CC_EVENTS.AGENT_WRAPUP: + this.currentTask = this.currentTask.updateTaskData(payload.data); + this.currentTask.emit(TASK_EVENTS.TASK_END, this.currentTask); + break; + case CC_EVENTS.AGENT_WRAPPEDUP: + this.removeCurrentTaskFromCollection(); + break; default: break; } @@ -84,6 +93,16 @@ export default class TaskManager extends EventEmitter { }); } + private removeCurrentTaskFromCollection() { + if (this.currentTask && this.currentTask.data && this.currentTask.data.interactionId) { + delete this.taskCollection[this.currentTask.data.interactionId]; + LoggerProxy.info(`Task removed from collection: ${this.currentTask.data.interactionId}`, { + module: TASK_MANAGER_FILE, + method: 'removeCurrentTaskFromCollection', + }); + } + } + /** * @param taskId - Unique identifier for each task */ diff --git a/packages/@webex/plugin-cc/src/services/task/index.ts b/packages/@webex/plugin-cc/src/services/task/index.ts index bad5fb65dee..e7312fcd029 100644 --- a/packages/@webex/plugin-cc/src/services/task/index.ts +++ b/packages/@webex/plugin-cc/src/services/task/index.ts @@ -5,7 +5,15 @@ import {getErrorDetails} from '../core/Utils'; import {LoginOption} from '../../types'; import {CC_FILE} from '../../constants'; import routingContact from './contact'; -import {ITask, TaskResponse, TaskData, TaskId, TASK_EVENTS} from './types'; +import { + ITask, + TaskResponse, + TaskData, + TaskId, + TASK_EVENTS, + WrapupPayLoad, + ResumeRecordingPayload, +} from './types'; import WebCallingService from '../WebCallingService'; export default class Task extends EventEmitter implements ITask { @@ -101,5 +109,136 @@ export default class Task extends EventEmitter implements ITask { } } - // TODO: Hold/resume, recording pause/resume, consult and transfer public methods to be implemented here + /** + * This is used to hold the task. + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.hold().then(()=>{}).catch(()=>{}) + * ``` + * */ + public async hold(): Promise { + try { + return this.contact.hold({ + interactionId: this.data.interactionId, + data: {mediaResourceId: this.data.mediaResourceId}, + }); + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'hold', CC_FILE); + throw detailedError; + } + } + + /** + * This is used to resume the task. + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.resume().then(()=>{}).catch(()=>{}) + * ``` + */ + public async resume(): Promise { + try { + return this.contact.unHold({ + interactionId: this.data.interactionId, + data: {mediaResourceId: this.data.mediaResourceId}, + }); + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'resume', CC_FILE); + throw detailedError; + } + } + + /** + * This is used to end the task. + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.end().then(()=>{}).catch(()=>{}) + * ``` + */ + public async end(): Promise { + try { + return this.contact.end({interactionId: this.data.interactionId}); + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'end', CC_FILE); + throw detailedError; + } + } + + /** + * This is used to wrap up the task. + * @param wrapupPayload - WrapupPayLoad + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.wrapup(wrapupPayload).then(()=>{}).catch(()=>{}) + * ``` + */ + public async wrapup(wrapupPayload: WrapupPayLoad): Promise { + try { + if (!this.data) { + throw new Error('No task data available'); + } + if (!wrapupPayload.auxCodeId || wrapupPayload.auxCodeId.length === 0) { + throw new Error('AuxCodeId is required'); + } + if (!wrapupPayload.wrapUpReason || wrapupPayload.wrapUpReason.length === 0) { + throw new Error('WrapUpReason is required'); + } + + return this.contact.wrapup({interactionId: this.data.interactionId, data: wrapupPayload}); + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'wrapup', CC_FILE); + throw detailedError; + } + } + + /** + * This is used to pause the call recording + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.pauseRecording().then(()=>{}).catch(()=>{}); + * ``` + */ + public async pauseRecording(): Promise { + try { + return this.contact.pauseRecording({interactionId: this.data.interactionId}); + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'pauseRecording', CC_FILE); + throw detailedError; + } + } + + /** + * This is used to pause the call recording + * @param resumeRecordingPayload + * @returns Promise + * @throws Error + * @example + * ```typescript + * task.resumeRecording(resumeRecordingPayload).then(()=>{}).catch(()=>{}); + * ``` + */ + public async resumeRecording( + resumeRecordingPayload: ResumeRecordingPayload + ): Promise { + try { + return this.contact.resumeRecording({ + interactionId: this.data.interactionId, + data: resumeRecordingPayload, + }); + } catch (error) { + const {error: detailedError} = getErrorDetails(error, 'resumeRecording', CC_FILE); + throw detailedError; + } + } + + // TODO: consult and transfer public methods to be implemented here } diff --git a/packages/@webex/plugin-cc/src/services/task/types.ts b/packages/@webex/plugin-cc/src/services/task/types.ts index a731403c204..ea38cc6e2c1 100644 --- a/packages/@webex/plugin-cc/src/services/task/types.ts +++ b/packages/@webex/plugin-cc/src/services/task/types.ts @@ -1,4 +1,5 @@ import {CallId} from '@webex/calling/dist/types/common/types'; +import EventEmitter from 'events'; import {Msg} from '../core/GlobalTypes'; export type TaskId = string; @@ -327,7 +328,7 @@ export type TaskResponse = AgentContact | Error | void; /** * Represents an interface for managing task related operations. */ -export interface ITask { +export interface ITask extends EventEmitter { /** * Event data received in the CC events */ @@ -362,5 +363,61 @@ export interface ITask { * ``` */ decline(taskId: TaskId): Promise; - // TODO: Add the remianing public methods + /** + * This is used to hold the task. + * @param taskId + * @returns Promise + * @example + * ``` + * task.hold(); + * ``` + */ + hold(): Promise; + /** + * This is used to resume the task. + * @returns Promise + * @example + * ``` + * task.resume(); + * ``` + */ + resume(): Promise; + /** + * This is used to end the task. + * @returns Promise + * @example + * ``` + * task.end(); + * ``` + */ + end(): Promise; + /** + * This is used to wrap up the task. + * @param wrapupPayload + * @returns Promise + * @example + * ``` + * task.wrapup(data); + * ``` + */ + wrapup(wrapupPayload: WrapupPayLoad): Promise; + /** + * This is used to pause the call recording. + * @returns Promise + * @example + * ``` + * task.wrapup(); + * ``` + */ + pauseRecording(): Promise; + /** + * This is used to resume the call recording. + * @param resumeRecordingPayload + * @returns Promise + * @example + * ``` + * task.resumeRecording(); + * ``` + */ + resumeRecording(resumeRecordingPayload: ResumeRecordingPayload): Promise; } diff --git a/packages/@webex/plugin-cc/test/unit/spec/cc.ts b/packages/@webex/plugin-cc/test/unit/spec/cc.ts index 9632f1c8aa4..cd944df02ad 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/cc.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/cc.ts @@ -766,7 +766,7 @@ describe('webex.cc', () => { jest.spyOn(webex.cc.services.agent, 'reload').mockRejectedValue(error); await webex.cc['silentRelogin'](); - expect(LoggerProxy.info).toHaveBeenCalledWith( + expect(LoggerProxy.log).toHaveBeenCalledWith( 'Agent not found during re-login, handling silently', {module: CC_FILE, method: 'silentRelogin'} ); diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts index 375f0d51343..894e4cd965c 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/TaskManager.ts @@ -13,7 +13,8 @@ import config from '../../../../../src/config'; jest.mock('./../../../../../src/services/task', () => { return jest.fn().mockImplementation(() => { return { - updateTaskData: jest.fn(), + updateTaskData: jest.fn().mockReturnThis(), + emit: jest.fn() }; }); }); @@ -147,11 +148,11 @@ describe('TaskManager', () => { }, }; - const taskAssignedSpy = jest.spyOn(taskManager, 'emit'); + const currentTaskAssignedSpy = jest.spyOn(taskManager.currentTask, 'emit'); webSocketManagerMock.emit('message', JSON.stringify(assignedPayload)); - expect(taskAssignedSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, taskManager.currentTask); + expect(currentTaskAssignedSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, taskManager.currentTask); }); it('should handle WebSocket message for AGENT_CONTACT_RESERVED and emit task:incoming for extension case', () => { @@ -292,4 +293,74 @@ describe('TaskManager', () => { taskManager.unregisterIncomingCallEvent(); expect(webCallingServiceOffSpy).toHaveBeenCalledWith(LINE_EVENTS.INCOMING_CALL, webCallingServiceOffSpy.mock.calls[0][1]); }); + + it('should emit TASK_END event on AGENT_WRAPUP event', () => { + // Need to setup the task with current task + const firstPayload = { + data: { + type: CC_EVENTS.AGENT_CONTACT_RESERVED, + agentId: "723a8ffb-a26e-496d-b14a-ff44fb83b64f", + eventTime: 1733211616959, + eventType: "RoutingMessage", + interaction: {}, + interactionId: "0ae913a4-c857-4705-8d49-76dd3dde75e4", + orgId: "6ecef209-9a34-4ed1-a07a-7ddd1dbe925a", + trackingId: "575c0ec2-618c-42af-a61c-53aeb0a221ee", + mediaResourceId: '0ae913a4-c857-4705-8d49-76dd3dde75e4', + destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2', + owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', + queueMgr: 'aqm', + }, + }; + webSocketManagerMock.emit('message', JSON.stringify(firstPayload)); + + const wrapupPayload = { + data: { + type: CC_EVENTS.AGENT_WRAPUP, + agentId: "723a8ffb-a26e-496d-b14a-ff44fb83b64f", + eventTime: 1733211616959, + eventType: "RoutingMessage", + interaction: {}, + interactionId: taskId, + orgId: "6ecef209-9a34-4ed1-a07a-7ddd1dbe925a", + trackingId: "575c0ec2-618c-42af-a61c-53aeb0a221ee", + mediaResourceId: '0ae913a4-c857-4705-8d49-76dd3dde75e4', + destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2', + owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', + queueMgr: 'aqm', + }, + }; + + const taskEmitSpy = jest.spyOn(taskManager.currentTask, 'emit'); + + webSocketManagerMock.emit('message', JSON.stringify(wrapupPayload)); + + expect(taskManager.currentTask.updateTaskData).toHaveBeenCalledWith(wrapupPayload.data); + expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, taskManager.currentTask); + }); + + it('should remove currentTask from taskCollection on AGENT_WRAPPEDUP event', () => { + const payload = { + data: { + type: CC_EVENTS.AGENT_WRAPPEDUP, + agentId: "723a8ffb-a26e-496d-b14a-ff44fb83b64f", + eventTime: 1733211616959, + eventType: "RoutingMessage", + interaction: {}, + interactionId: taskId, + orgId: "6ecef209-9a34-4ed1-a07a-7ddd1dbe925a", + trackingId: "575c0ec2-618c-42af-a61c-53aeb0a221ee", + mediaResourceId: '0ae913a4-c857-4705-8d49-76dd3dde75e4', + destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2', + owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f', + queueMgr: 'aqm', + }, + }; + + taskManager.taskCollection[taskId] = taskManager.currentTask; + + webSocketManagerMock.emit('message', JSON.stringify(payload)); + + expect(taskManager.getTask(taskId)).toBeUndefined(); + }); }); \ No newline at end of file diff --git a/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts b/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts index c54b37cea0f..fb0f6602716 100644 --- a/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts +++ b/packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts @@ -7,7 +7,7 @@ import * as Utils from '../../../../../src/services/core/Utils'; import { CC_EVENTS } from '../../../../../src/services/config/types'; import config from '../../../../../src/config'; import WebCallingService from '../../../../../src/services/WebCallingService'; -import { TASK_EVENTS } from '../../../../../src/services/task/types'; +import { TASK_EVENTS, TaskResponse, AgentContact } from '../../../../../src/services/task/types'; jest.mock('@webex/calling'); @@ -39,6 +39,12 @@ describe('Task', () => { contactMock = { accept: jest.fn().mockResolvedValue({}), + hold: jest.fn().mockResolvedValue({}), + unHold: jest.fn().mockResolvedValue({}), + end: jest.fn().mockResolvedValue({}), + wrapup: jest.fn().mockResolvedValue({}), + pauseRecording: jest.fn().mockResolvedValue({}), + resumeRecording: jest.fn().mockResolvedValue({}) }; webCallingService = new WebCallingService( @@ -182,4 +188,179 @@ describe('Task', () => { await expect(task.decline()).rejects.toThrow(new Error(error.details.data.reason)); expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'decline', CC_FILE); }); + + it('should hold the task and return the expected response', async () => { + const expectedResponse: TaskResponse = { data: { interactionId: taskId } } as AgentContact; + contactMock.hold.mockResolvedValue(expectedResponse); + + const response = await task.hold(); + + expect(contactMock.hold).toHaveBeenCalledWith({ interactionId: taskId, data: { mediaResourceId: taskDataMock.mediaResourceId } }); + expect(response).toEqual(expectedResponse); + }); + + it('should handle errors in hold method', async () => { + const error = { + details: { + trackingId: '1234', + data: { + reason: 'Hold Failed', + }, + }, + }; + contactMock.hold.mockImplementation(() => { throw error; }); + + await expect(task.hold()).rejects.toThrow(error.details.data.reason); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'hold', CC_FILE); + }); + + it('should resume the task and return the expected response', async () => { + const expectedResponse: TaskResponse = { data: { interactionId: taskId } } as AgentContact; + contactMock.unHold.mockResolvedValue(expectedResponse); + + const response = await task.resume(); + + expect(contactMock.unHold).toHaveBeenCalledWith({ interactionId: taskId, data: { mediaResourceId: taskDataMock.mediaResourceId } }); + expect(response).toEqual(expectedResponse); + }); + + it('should handle errors in resume method', async () => { + const error = { + details: { + trackingId: '1234', + data: { + reason: 'Resume Failed', + }, + }, + }; + contactMock.unHold.mockImplementation(() => { throw error; }); + + await expect(task.resume()).rejects.toThrow(error.details.data.reason); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'resume', CC_FILE); + }); + + it('should end the task and return the expected response', async () => { + const expectedResponse: TaskResponse = { data: { interactionId: taskId } } as AgentContact; + contactMock.end.mockResolvedValue(expectedResponse); + + const response = await task.end(); + + expect(contactMock.end).toHaveBeenCalledWith({ interactionId: taskId }); + expect(response).toEqual(expectedResponse); + }); + + it('should handle errors in end method', async () => { + const error = { + details: { + trackingId: '1234', + data: { + reason: 'End Failed', + }, + }, + }; + contactMock.end.mockImplementation(() => { throw error; }); + + await expect(task.end()).rejects.toThrow(error.details.data.reason); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'end', CC_FILE); + }); + + it('should wrap up the task and return the expected response', async () => { + const expectedResponse: TaskResponse = { data: { interactionId: taskId } } as AgentContact; + const wrapupPayload = { + wrapUpReason: 'Customer request', + auxCodeId: 'auxCodeId123' + }; + contactMock.wrapup.mockResolvedValue(expectedResponse); + + const response = await task.wrapup(wrapupPayload); + + expect(contactMock.wrapup).toHaveBeenCalledWith({ interactionId: taskId, data: wrapupPayload }); + expect(response).toEqual(expectedResponse); + }); + + it('should handle errors in wrapup method', async () => { + const error = { + details: { + trackingId: '1234', + data: { + reason: 'Wrapup Failed', + }, + }, + }; + contactMock.wrapup.mockImplementation(() => { throw error; }); + + const wrapupPayload = { + wrapUpReason: 'Customer request', + auxCodeId: 'auxCodeId123' + }; + + await expect(task.wrapup(wrapupPayload)).rejects.toThrow(error.details.data.reason); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'wrapup', CC_FILE); + }); + + it('should throw an error if auxCodeId is missing in wrapup method', async () => { + const wrapupPayload = { + wrapUpReason: 'Customer request', + auxCodeId: '' + }; + await expect(task.wrapup(wrapupPayload)).rejects.toThrow(); + }); + + it('should throw an error if wrapUpReason is missing in wrapup method', async () => { + const wrapupPayload = { + wrapUpReason: '', + auxCodeId: 'auxCodeId123' + }; + await expect(task.wrapup(wrapupPayload)).rejects.toThrow(); + }); + + it('should pause the recording of the task', async () => { + await task.pauseRecording(); + + expect(contactMock.pauseRecording).toHaveBeenCalledWith({ interactionId: taskId }); + }); + + it('should handle errors in pauseRecording method', async () => { + const error = { + details: { + trackingId: '1234', + data: { + reason: 'Pause Recording Failed', + }, + }, + }; + contactMock.pauseRecording.mockImplementation(() => { throw error; }); + + await expect(task.pauseRecording()).rejects.toThrow(error.details.data.reason); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'pauseRecording', CC_FILE); + }); + + it('should resume the recording of the task', async () => { + const resumePayload = { + autoResumed: true + }; + + await task.resumeRecording(resumePayload); + + expect(contactMock.resumeRecording).toHaveBeenCalledWith({ interactionId: taskId, data: resumePayload }); + }); + + it('should handle errors in resumeRecording method', async () => { + const error = { + details: { + trackingId: '1234', + data: { + reason: 'Resume Recording Failed', + }, + }, + }; + contactMock.resumeRecording.mockImplementation(() => { throw error; }); + + const resumePayload = { + autoResumed: true + }; + + await expect(task.resumeRecording(resumePayload)).rejects.toThrow(error.details.data.reason); + expect(getErrorDetailsSpy).toHaveBeenCalledWith(error, 'resumeRecording', CC_FILE); + }); }); \ No newline at end of file