@webex/contact-center 3.12.0-next.13 → 3.12.0-next.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.sdd/manifest.json +884 -0
- package/AGENTS.md +94 -0
- package/ai-docs/ARCHITECTURE.md +168 -0
- package/ai-docs/CONTRACTS.md +53 -0
- package/ai-docs/GETTING_STARTED.md +168 -0
- package/ai-docs/GLOSSARY.md +43 -0
- package/ai-docs/README.md +138 -0
- package/ai-docs/REVIEW_CHECKLIST.md +41 -0
- package/ai-docs/RULES.md +444 -0
- package/ai-docs/SECURITY.md +52 -0
- package/ai-docs/SERVICE_STATE.md +52 -0
- package/ai-docs/SPEC_INDEX.md +65 -0
- package/ai-docs/adr/0001-spec-source-policy.md +55 -0
- package/ai-docs/adr/README.md +8 -0
- package/ai-docs/adr/_adr-template.md +31 -0
- package/ai-docs/contact-center-spec.md +382 -0
- package/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +362 -0
- package/ai-docs/features/contact-owner-change-refactor.md +78 -0
- package/ai-docs/features/generated-spec-conformance-fidelity-remediation/spec/feature-spec.md +117 -0
- package/ai-docs/features/residual-warning-coverage-completion/spec/feature-spec.md +203 -0
- package/ai-docs/features/validator-code-fidelity-drift-fix/spec/feature-spec.md +315 -0
- package/ai-docs/patterns/event-driven-patterns.md +485 -0
- package/ai-docs/patterns/testing-patterns.md +480 -0
- package/ai-docs/patterns/typescript-patterns.md +365 -0
- package/ai-docs/templates/README.md +102 -0
- package/ai-docs/templates/documentation/create-agents-md.md +240 -0
- package/ai-docs/templates/documentation/create-architecture-md.md +295 -0
- package/ai-docs/templates/existing-service/bug-fix.md +254 -0
- package/ai-docs/templates/existing-service/feature-enhancement.md +450 -0
- package/ai-docs/templates/new-method/00-master.md +80 -0
- package/ai-docs/templates/new-method/01-requirements.md +232 -0
- package/ai-docs/templates/new-method/02-implementation.md +295 -0
- package/ai-docs/templates/new-method/03-tests.md +201 -0
- package/ai-docs/templates/new-method/04-validation.md +141 -0
- package/ai-docs/templates/new-service/00-master.md +109 -0
- package/ai-docs/templates/new-service/01-pre-questions.md +159 -0
- package/ai-docs/templates/new-service/02-code-generation.md +346 -0
- package/ai-docs/templates/new-service/03-integration.md +178 -0
- package/ai-docs/templates/new-service/04-test-generation.md +205 -0
- package/ai-docs/templates/new-service/05-validation.md +145 -0
- package/dist/cc.js +971 -42
- package/dist/cc.js.map +1 -1
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/constants.js +31 -3
- package/dist/constants.js.map +1 -1
- package/dist/index.js +45 -15
- package/dist/index.js.map +1 -1
- package/dist/metrics/MetricsManager.js +16 -0
- package/dist/metrics/MetricsManager.js.map +1 -1
- package/dist/metrics/behavioral-events.js +205 -0
- package/dist/metrics/behavioral-events.js.map +1 -1
- package/dist/metrics/constants.js +48 -4
- package/dist/metrics/constants.js.map +1 -1
- package/dist/services/AddressBook.js +18 -15
- package/dist/services/AddressBook.js.map +1 -1
- package/dist/services/AnswerCallOnWebexService.js +202 -0
- package/dist/services/AnswerCallOnWebexService.js.map +1 -0
- package/dist/services/ApiAiAssistant.js +269 -9
- package/dist/services/ApiAiAssistant.js.map +1 -1
- package/dist/services/EntryPoint.js +46 -68
- package/dist/services/EntryPoint.js.map +1 -1
- package/dist/services/Queue.js +27 -22
- package/dist/services/Queue.js.map +1 -1
- package/dist/services/UserPreference.js +427 -0
- package/dist/services/UserPreference.js.map +1 -0
- package/dist/services/WebexCrossClientService.js +222 -0
- package/dist/services/WebexCrossClientService.js.map +1 -0
- package/dist/services/WxAppTelephonyMercurySync.js +108 -0
- package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
- package/dist/services/config/Util.js +11 -4
- package/dist/services/config/Util.js.map +1 -1
- package/dist/services/config/constants.js +41 -7
- package/dist/services/config/constants.js.map +1 -1
- package/dist/services/config/index.js +51 -0
- package/dist/services/config/index.js.map +1 -1
- package/dist/services/config/types.js +57 -12
- package/dist/services/config/types.js.map +1 -1
- package/dist/services/core/Err.js.map +1 -1
- package/dist/services/core/Utils.js +111 -32
- package/dist/services/core/Utils.js.map +1 -1
- package/dist/services/core/WebexRequest.js +6 -2
- package/dist/services/core/WebexRequest.js.map +1 -1
- package/dist/services/core/aqm-reqs.js +28 -14
- package/dist/services/core/aqm-reqs.js.map +1 -1
- package/dist/services/core/types.js.map +1 -1
- package/dist/services/core/websocket/WebSocketManager.js +2 -1
- package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
- package/dist/services/core/websocket/types.js.map +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/index.js.map +1 -1
- package/dist/services/task/Task.js +754 -0
- package/dist/services/task/Task.js.map +1 -0
- package/dist/services/task/TaskFactory.js +49 -0
- package/dist/services/task/TaskFactory.js.map +1 -0
- package/dist/services/task/TaskManager.js +1172 -521
- package/dist/services/task/TaskManager.js.map +1 -1
- package/dist/services/task/TaskUtils.js +211 -27
- package/dist/services/task/TaskUtils.js.map +1 -1
- package/dist/services/task/WebexCallingUtils.js +70 -0
- package/dist/services/task/WebexCallingUtils.js.map +1 -0
- package/dist/services/task/constants.js +12 -2
- package/dist/services/task/constants.js.map +1 -1
- package/dist/services/task/contact.js +29 -0
- package/dist/services/task/contact.js.map +1 -1
- package/dist/services/task/dialer.js +78 -0
- package/dist/services/task/dialer.js.map +1 -1
- package/dist/services/task/digital/Digital.js +78 -0
- package/dist/services/task/digital/Digital.js.map +1 -0
- package/dist/services/task/state-machine/TaskStateMachine.js +982 -0
- package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
- package/dist/services/task/state-machine/actions.js +572 -0
- package/dist/services/task/state-machine/actions.js.map +1 -0
- package/dist/services/task/state-machine/constants.js +161 -0
- package/dist/services/task/state-machine/constants.js.map +1 -0
- package/dist/services/task/state-machine/guards.js +379 -0
- package/dist/services/task/state-machine/guards.js.map +1 -0
- package/dist/services/task/state-machine/index.js +53 -0
- package/dist/services/task/state-machine/index.js.map +1 -0
- package/dist/services/task/state-machine/types.js +54 -0
- package/dist/services/task/state-machine/types.js.map +1 -0
- package/dist/services/task/state-machine/uiControlsComputer.js +703 -0
- package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
- package/dist/services/task/taskDataNormalizer.js +99 -0
- package/dist/services/task/taskDataNormalizer.js.map +1 -0
- package/dist/services/task/types.js +228 -5
- package/dist/services/task/types.js.map +1 -1
- package/dist/services/task/voice/Voice.js +1485 -0
- package/dist/services/task/voice/Voice.js.map +1 -0
- package/dist/services/task/voice/WebRTC.js +152 -0
- package/dist/services/task/voice/WebRTC.js.map +1 -0
- package/dist/services/task/voice/wxAppVoiceMethods.js +354 -0
- package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
- package/dist/services/wxAppDiagnosticLogging.js +122 -0
- package/dist/services/wxAppDiagnosticLogging.js.map +1 -0
- package/dist/services/wxAppTelephonyUtils.js +19 -0
- package/dist/services/wxAppTelephonyUtils.js.map +1 -0
- package/dist/types/cc.d.ts +193 -6
- package/dist/types/config.d.ts +13 -0
- package/dist/types/constants.d.ts +29 -1
- package/dist/types/index.d.ts +35 -8
- package/dist/types/metrics/MetricsManager.d.ts +6 -0
- package/dist/types/metrics/constants.d.ts +43 -1
- package/dist/types/services/AddressBook.d.ts +2 -1
- package/dist/types/services/AnswerCallOnWebexService.d.ts +38 -0
- package/dist/types/services/ApiAiAssistant.d.ts +56 -5
- package/dist/types/services/EntryPoint.d.ts +7 -5
- package/dist/types/services/Queue.d.ts +5 -3
- package/dist/types/services/UserPreference.d.ts +118 -0
- package/dist/types/services/WebexCrossClientService.d.ts +32 -0
- package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
- package/dist/types/services/config/constants.d.ts +34 -5
- package/dist/types/services/config/types.d.ts +197 -12
- package/dist/types/services/core/Err.d.ts +6 -0
- package/dist/types/services/core/Utils.d.ts +35 -13
- package/dist/types/services/core/WebexRequest.d.ts +1 -0
- package/dist/types/services/core/types.d.ts +2 -0
- package/dist/types/services/core/websocket/WebSocketManager.d.ts +1 -0
- package/dist/types/services/core/websocket/types.d.ts +1 -1
- package/dist/types/services/index.d.ts +1 -1
- package/dist/types/services/task/Task.d.ts +175 -0
- package/dist/types/services/task/TaskFactory.d.ts +13 -0
- package/dist/types/services/task/TaskUtils.d.ts +48 -2
- package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
- package/dist/types/services/task/constants.d.ts +10 -0
- package/dist/types/services/task/contact.d.ts +4 -0
- package/dist/types/services/task/dialer.d.ts +30 -0
- package/dist/types/services/task/digital/Digital.d.ts +22 -0
- package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +1442 -0
- package/dist/types/services/task/state-machine/actions.d.ts +10 -0
- package/dist/types/services/task/state-machine/constants.d.ts +107 -0
- package/dist/types/services/task/state-machine/guards.d.ts +103 -0
- package/dist/types/services/task/state-machine/index.d.ts +13 -0
- package/dist/types/services/task/state-machine/types.d.ts +277 -0
- package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
- package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
- package/dist/types/services/task/types.d.ts +680 -66
- package/dist/types/services/task/voice/Voice.d.ts +227 -0
- package/dist/types/services/task/voice/WebRTC.d.ts +53 -0
- package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +55 -0
- package/dist/types/services/wxAppDiagnosticLogging.d.ts +51 -0
- package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
- package/dist/types/types.d.ts +205 -9
- package/dist/types/utils/PageCache.d.ts +20 -3
- package/dist/types/webex.d.ts +1 -0
- package/dist/types.js +162 -4
- package/dist/types.js.map +1 -1
- package/dist/utils/PageCache.js +19 -5
- package/dist/utils/PageCache.js.map +1 -1
- package/dist/webex.js +22 -2
- package/dist/webex.js.map +1 -1
- package/package.json +16 -12
- package/src/cc.ts +1289 -112
- package/src/config.ts +13 -0
- package/src/constants.ts +29 -1
- package/src/index.ts +36 -5
- package/src/metrics/MetricsManager.ts +16 -0
- package/src/metrics/ai-docs/AGENTS.md +350 -0
- package/src/metrics/ai-docs/ARCHITECTURE.md +338 -0
- package/src/metrics/ai-docs/metrics-spec.md +921 -0
- package/src/metrics/behavioral-events.ts +212 -0
- package/src/metrics/constants.ts +52 -4
- package/src/services/AddressBook.ts +17 -6
- package/src/services/AnswerCallOnWebexService.ts +239 -0
- package/src/services/ApiAiAssistant.ts +356 -9
- package/src/services/EntryPoint.ts +59 -60
- package/src/services/Queue.ts +29 -12
- package/src/services/UserPreference.ts +509 -0
- package/src/services/WebexCrossClientService.ts +293 -0
- package/src/services/WxAppTelephonyMercurySync.ts +142 -0
- package/src/services/agent/ai-docs/AGENTS.md +240 -0
- package/src/services/agent/ai-docs/ARCHITECTURE.md +304 -0
- package/src/services/agent/ai-docs/agent-spec.md +505 -0
- package/src/services/ai-docs/AGENTS.md +386 -0
- package/src/services/ai-docs/services-spec.md +505 -0
- package/src/services/config/Util.ts +12 -3
- package/src/services/config/ai-docs/AGENTS.md +255 -0
- package/src/services/config/ai-docs/ARCHITECTURE.md +426 -0
- package/src/services/config/ai-docs/config-spec.md +686 -0
- package/src/services/config/constants.ts +46 -6
- package/src/services/config/index.ts +65 -0
- package/src/services/config/types.ts +197 -13
- package/src/services/core/Err.ts +3 -0
- package/src/services/core/Utils.ts +127 -37
- package/src/services/core/WebexRequest.ts +3 -1
- package/src/services/core/ai-docs/AGENTS.md +381 -0
- package/src/services/core/ai-docs/ARCHITECTURE.md +698 -0
- package/src/services/core/ai-docs/core-spec.md +787 -0
- package/src/services/core/aqm-reqs.ts +30 -15
- package/src/services/core/types.ts +2 -0
- package/src/services/core/websocket/WebSocketManager.ts +2 -0
- package/src/services/core/websocket/types.ts +1 -1
- package/src/services/index.ts +1 -1
- package/src/services/task/Task.ts +908 -0
- package/src/services/task/TaskFactory.ts +60 -0
- package/src/services/task/TaskManager.ts +1419 -608
- package/src/services/task/TaskUtils.ts +303 -25
- package/src/services/task/WebexCallingUtils.ts +136 -0
- package/src/services/task/ai-docs/AGENTS.md +457 -0
- package/src/services/task/ai-docs/ARCHITECTURE.md +595 -0
- package/src/services/task/ai-docs/task-spec.md +1474 -0
- package/src/services/task/constants.ts +10 -0
- package/src/services/task/contact.ts +30 -0
- package/src/services/task/dialer.ts +80 -0
- package/src/services/task/digital/Digital.ts +97 -0
- package/src/services/task/state-machine/TaskStateMachine.ts +1320 -0
- package/src/services/task/state-machine/actions.ts +741 -0
- package/src/services/task/state-machine/ai-docs/AGENTS.md +460 -0
- package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1156 -0
- package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +2247 -0
- package/src/services/task/state-machine/constants.ts +172 -0
- package/src/services/task/state-machine/guards.ts +442 -0
- package/src/services/task/state-machine/index.ts +28 -0
- package/src/services/task/state-machine/types.ts +258 -0
- package/src/services/task/state-machine/uiControlsComputer.ts +1135 -0
- package/src/services/task/taskDataNormalizer.ts +137 -0
- package/src/services/task/types.ts +819 -70
- package/src/services/task/voice/Voice.ts +1851 -0
- package/src/services/task/voice/WebRTC.ts +191 -0
- package/src/services/task/voice/wxAppVoiceMethods.ts +516 -0
- package/src/services/wxAppDiagnosticLogging.ts +174 -0
- package/src/services/wxAppTelephonyUtils.ts +14 -0
- package/src/types.ts +262 -11
- package/src/utils/AGENTS.md +289 -0
- package/src/utils/PageCache.ts +38 -5
- package/src/utils/ai-docs/utils-spec.md +391 -0
- package/src/webex.js +10 -0
- package/test/unit/spec/cc.ts +2210 -119
- package/test/unit/spec/logger-proxy.ts +70 -0
- package/test/unit/spec/metrics/MetricsManager.ts +25 -0
- package/test/unit/spec/metrics/behavioral-events.ts +60 -0
- package/test/unit/spec/services/AddressBook.ts +37 -6
- package/test/unit/spec/services/AnswerCallOnWebexService.ts +264 -0
- package/test/unit/spec/services/ApiAiAssistant.ts +311 -21
- package/test/unit/spec/services/EntryPoint.ts +87 -40
- package/test/unit/spec/services/Queue.ts +123 -12
- package/test/unit/spec/services/UserPreference.ts +401 -0
- package/test/unit/spec/services/WebCallingService.ts +7 -1
- package/test/unit/spec/services/WebexCrossClientService.ts +467 -0
- package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +136 -0
- package/test/unit/spec/services/config/Util.ts +129 -0
- package/test/unit/spec/services/config/index.ts +97 -29
- package/test/unit/spec/services/core/Utils.ts +425 -8
- package/test/unit/spec/services/core/WebexRequest.ts +3 -1
- package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
- package/test/unit/spec/services/core/websocket/WebSocketManager.ts +66 -40
- package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
- package/test/unit/spec/services/task/Task.ts +745 -0
- package/test/unit/spec/services/task/TaskFactory.ts +99 -0
- package/test/unit/spec/services/task/TaskManager.ts +2518 -1494
- package/test/unit/spec/services/task/TaskUtils.ts +235 -0
- package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
- package/test/unit/spec/services/task/contact.ts +33 -0
- package/test/unit/spec/services/task/dialer.ts +190 -0
- package/test/unit/spec/services/task/digital/Digital.ts +105 -0
- package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +4060 -0
- package/test/unit/spec/services/task/state-machine/guards.ts +839 -0
- package/test/unit/spec/services/task/state-machine/types.ts +18 -0
- package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +3101 -0
- package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
- package/test/unit/spec/services/task/voice/Voice.ts +2061 -0
- package/test/unit/spec/services/task/voice/WebRTC.ts +235 -0
- package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +603 -0
- package/test/unit/spec/services/wxAppDiagnosticLogging.ts +143 -0
- package/test/unit/spec/wellnessSampleUtils.ts +288 -0
- package/umd/contact-center.min.js +2 -2
- package/umd/contact-center.min.js.map +1 -1
- package/dist/services/task/index.js +0 -1525
- package/dist/services/task/index.js.map +0 -1
- package/dist/types/services/task/index.d.ts +0 -650
- package/src/services/task/index.ts +0 -1801
- package/test/unit/spec/services/task/index.ts +0 -2184
|
@@ -0,0 +1,2061 @@
|
|
|
1
|
+
import Voice from '../../../../../../src/services/task/voice/Voice';
|
|
2
|
+
import LoggerProxy from '../../../../../../src/logger-proxy';
|
|
3
|
+
import {
|
|
4
|
+
TaskData,
|
|
5
|
+
CONSULT_TRANSFER_DESTINATION_TYPE,
|
|
6
|
+
TASK_EVENTS,
|
|
7
|
+
VOICE_VARIANT,
|
|
8
|
+
} from '../../../../../../src/services/task/types';
|
|
9
|
+
import {CC_EVENTS} from '../../../../../../src/services/config/types';
|
|
10
|
+
import {TaskEvent, TaskState} from '../../../../../../src/services/task/state-machine';
|
|
11
|
+
import {computeUIControls} from '../../../../../../src/services/task/state-machine/uiControlsComputer';
|
|
12
|
+
import * as Utils from '../../../../../../src/services/core/Utils';
|
|
13
|
+
import {createTaskData} from '../taskTestUtils';
|
|
14
|
+
import MetricsManager from '../../../../../../src/metrics/MetricsManager';
|
|
15
|
+
import {METRIC_EVENT_NAMES} from '../../../../../../src/metrics/constants';
|
|
16
|
+
import * as wxAppDiagnosticLogging from '../../../../../../src/services/wxAppDiagnosticLogging';
|
|
17
|
+
|
|
18
|
+
jest.mock('../../../../../../src/services/core/WebexRequest', () => ({
|
|
19
|
+
__esModule: true,
|
|
20
|
+
default: {
|
|
21
|
+
getInstance: () => ({uploadLogs: jest.fn()}),
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('../../../../../../src/services/core/Utils', () => {
|
|
26
|
+
const actual = jest.requireActual('../../../../../../src/services/core/Utils');
|
|
27
|
+
return {
|
|
28
|
+
__esModule: true,
|
|
29
|
+
...actual,
|
|
30
|
+
// keep tests deterministic; avoid log upload side-effects on failures
|
|
31
|
+
getErrorDetails: (err: any) => ({error: err}),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const dummyContact = {
|
|
36
|
+
hold: jest.fn().mockResolvedValue('held'),
|
|
37
|
+
unHold: jest.fn().mockResolvedValue('resumed'),
|
|
38
|
+
pauseRecording: jest.fn().mockResolvedValue('paused'),
|
|
39
|
+
resumeRecording: jest.fn().mockResolvedValue('resumedRecording'),
|
|
40
|
+
consult: jest.fn().mockResolvedValue('consulted'),
|
|
41
|
+
consultConference: jest.fn().mockResolvedValue('conferenceStarted'),
|
|
42
|
+
dropConferenceParticipant: jest.fn().mockResolvedValue({
|
|
43
|
+
type: 'RoutingMessage',
|
|
44
|
+
trackingId: 'drop-tracking-id',
|
|
45
|
+
data: {},
|
|
46
|
+
}),
|
|
47
|
+
consultTransfer: jest.fn().mockResolvedValue('consultTransferred'),
|
|
48
|
+
cancelTask: jest.fn().mockResolvedValue(undefined),
|
|
49
|
+
exitConference: jest.fn().mockResolvedValue({
|
|
50
|
+
trackingId: 'exit-tracking-id',
|
|
51
|
+
data: {},
|
|
52
|
+
}),
|
|
53
|
+
} as any;
|
|
54
|
+
|
|
55
|
+
const createBaseData = (overrides: Partial<TaskData> = {}): TaskData =>
|
|
56
|
+
createTaskData({
|
|
57
|
+
interactionId: 'int1',
|
|
58
|
+
mediaResourceId: 'media1',
|
|
59
|
+
interaction: {
|
|
60
|
+
...(overrides.interaction || {}),
|
|
61
|
+
media: {
|
|
62
|
+
media1: {mediaResourceId: 'media1', isHold: false},
|
|
63
|
+
...(overrides.interaction as any)?.media,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
...overrides,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const primeConnectedState = (voice: Voice, taskData: TaskData) => {
|
|
70
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
71
|
+
voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const primeHeldState = (voice: Voice, taskData: TaskData) => {
|
|
75
|
+
primeConnectedState(voice, taskData);
|
|
76
|
+
voice.stateMachineService?.send({
|
|
77
|
+
type: TaskEvent.HOLD_INITIATED,
|
|
78
|
+
mediaResourceId: taskData.mediaResourceId,
|
|
79
|
+
});
|
|
80
|
+
voice.stateMachineService?.send({
|
|
81
|
+
type: TaskEvent.HOLD_SUCCESS,
|
|
82
|
+
mediaResourceId: taskData.mediaResourceId,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe('Voice Task', () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
jest.clearAllMocks();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('emitTaskOutdialFailed', () => {
|
|
92
|
+
it('emits the failure reason string instead of the Task object', () => {
|
|
93
|
+
const taskData = createBaseData({
|
|
94
|
+
interaction: {
|
|
95
|
+
outboundType: 'OUTDIAL',
|
|
96
|
+
} as any,
|
|
97
|
+
});
|
|
98
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
99
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
100
|
+
|
|
101
|
+
voice.sendStateMachineEvent({
|
|
102
|
+
type: TaskEvent.OUTBOUND_FAILED,
|
|
103
|
+
taskData,
|
|
104
|
+
reason: 'CUSTOMER_BUSY',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const outdialFailedCall = emitSpy.mock.calls.find(
|
|
108
|
+
(call) => call[0] === 'task:outdialFailed'
|
|
109
|
+
);
|
|
110
|
+
expect(outdialFailedCall).toBeDefined();
|
|
111
|
+
expect(outdialFailedCall![1]).toBe('CUSTOMER_BUSY');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not emit task:outdialFailed when suppressOutdialFailedPopup is set', () => {
|
|
115
|
+
const taskData = createBaseData({
|
|
116
|
+
interaction: {
|
|
117
|
+
outboundType: 'OUTDIAL',
|
|
118
|
+
} as any,
|
|
119
|
+
suppressOutdialFailedPopup: true,
|
|
120
|
+
});
|
|
121
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
122
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
123
|
+
|
|
124
|
+
voice.sendStateMachineEvent({
|
|
125
|
+
type: TaskEvent.OUTBOUND_FAILED,
|
|
126
|
+
taskData,
|
|
127
|
+
reason: 'AGENT_ENDS',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const outdialFailedCall = emitSpy.mock.calls.find(
|
|
131
|
+
(call) => call[0] === 'task:outdialFailed'
|
|
132
|
+
);
|
|
133
|
+
expect(outdialFailedCall).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('hides end and endConsult when disabled', () => {
|
|
138
|
+
const voice = new Voice(dummyContact, createBaseData(), {
|
|
139
|
+
isEndTaskEnabled: false,
|
|
140
|
+
isEndConsultEnabled: false,
|
|
141
|
+
});
|
|
142
|
+
voice.updateTaskData(createBaseData());
|
|
143
|
+
expect(voice.uiControls.main.end.isVisible).toBe(false);
|
|
144
|
+
expect(voice.uiControls.main.endConsult.isVisible).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('calls contact.hold when media is not held', async () => {
|
|
148
|
+
const taskData = createBaseData() as any;
|
|
149
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
150
|
+
primeConnectedState(voice, taskData);
|
|
151
|
+
await voice.holdResume();
|
|
152
|
+
expect(dummyContact.hold).toHaveBeenCalledWith({
|
|
153
|
+
interactionId: 'int1',
|
|
154
|
+
data: {mediaResourceId: 'media1'},
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('calls contact.unHold when media is held', async () => {
|
|
159
|
+
const heldData = createBaseData({
|
|
160
|
+
interaction: {
|
|
161
|
+
media: {media1: {mediaResourceId: 'media1', isHold: true}},
|
|
162
|
+
} as any,
|
|
163
|
+
}) as any;
|
|
164
|
+
const voice = new Voice(dummyContact, heldData, {});
|
|
165
|
+
primeHeldState(voice, heldData);
|
|
166
|
+
await voice.holdResume();
|
|
167
|
+
expect(dummyContact.unHold).toHaveBeenCalledWith({
|
|
168
|
+
interactionId: 'int1',
|
|
169
|
+
data: {mediaResourceId: 'media1'},
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('calls contact.unHold when state is held even if task data hold flag is stale', async () => {
|
|
174
|
+
const taskData = createBaseData() as any;
|
|
175
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
176
|
+
primeHeldState(voice, taskData);
|
|
177
|
+
voice.data.interaction.media.media1.isHold = false;
|
|
178
|
+
|
|
179
|
+
await voice.holdResume();
|
|
180
|
+
|
|
181
|
+
expect(dummyContact.unHold).toHaveBeenCalledWith({
|
|
182
|
+
interactionId: 'int1',
|
|
183
|
+
data: {mediaResourceId: 'media1'},
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('calls contact.unHold when state is connected and main media is held', async () => {
|
|
188
|
+
const heldConnectedData = createBaseData({
|
|
189
|
+
interaction: {
|
|
190
|
+
media: {media1: {mediaResourceId: 'media1', isHold: true}},
|
|
191
|
+
} as any,
|
|
192
|
+
}) as any;
|
|
193
|
+
const voice = new Voice(dummyContact, heldConnectedData, {});
|
|
194
|
+
primeConnectedState(voice, heldConnectedData);
|
|
195
|
+
|
|
196
|
+
await voice.holdResume();
|
|
197
|
+
|
|
198
|
+
expect(dummyContact.unHold).toHaveBeenCalledWith({
|
|
199
|
+
interactionId: 'int1',
|
|
200
|
+
data: {mediaResourceId: 'media1'},
|
|
201
|
+
});
|
|
202
|
+
expect(dummyContact.hold).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('calls contact.unHold when state is conferencing and main media is held', async () => {
|
|
206
|
+
const heldConferenceData = createBaseData({
|
|
207
|
+
interaction: {
|
|
208
|
+
state: 'conference',
|
|
209
|
+
media: {media1: {mediaResourceId: 'media1', isHold: true}},
|
|
210
|
+
} as any,
|
|
211
|
+
}) as any;
|
|
212
|
+
const voice = new Voice(dummyContact, heldConferenceData, {});
|
|
213
|
+
primeConnectedState(voice, heldConferenceData);
|
|
214
|
+
voice.stateMachineService?.send({type: TaskEvent.CONFERENCE_START, taskData: heldConferenceData});
|
|
215
|
+
expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.CONFERENCING);
|
|
216
|
+
|
|
217
|
+
await voice.holdResume();
|
|
218
|
+
|
|
219
|
+
expect(dummyContact.unHold).toHaveBeenCalledWith({
|
|
220
|
+
interactionId: 'int1',
|
|
221
|
+
data: {mediaResourceId: 'media1'},
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('uses the main media resource when stale consult media is left in task data', async () => {
|
|
226
|
+
const heldData = createBaseData({
|
|
227
|
+
mediaResourceId: 'main-media',
|
|
228
|
+
interactionId: 'main-int',
|
|
229
|
+
interaction: {
|
|
230
|
+
mainInteractionId: 'main-int',
|
|
231
|
+
media: {
|
|
232
|
+
'main-int': {mediaResourceId: 'main-media', isHold: true},
|
|
233
|
+
'consult-media': {mediaResourceId: 'consult-media', isHold: false, mType: 'consult'},
|
|
234
|
+
},
|
|
235
|
+
} as any,
|
|
236
|
+
}) as any;
|
|
237
|
+
const voice = new Voice(dummyContact, heldData, {});
|
|
238
|
+
primeHeldState(voice, heldData);
|
|
239
|
+
voice.data.mediaResourceId = 'consult-media';
|
|
240
|
+
|
|
241
|
+
await voice.holdResume();
|
|
242
|
+
|
|
243
|
+
expect(dummyContact.unHold).toHaveBeenCalledWith({
|
|
244
|
+
interactionId: 'main-int',
|
|
245
|
+
data: {mediaResourceId: 'main-media'},
|
|
246
|
+
});
|
|
247
|
+
expect(dummyContact.hold).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('pauseRecording() calls contact.pauseRecording', async () => {
|
|
251
|
+
const taskData = createBaseData();
|
|
252
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
253
|
+
isEndTaskEnabled: true,
|
|
254
|
+
isEndConsultEnabled: true,
|
|
255
|
+
});
|
|
256
|
+
primeConnectedState(voice, taskData);
|
|
257
|
+
const res = await voice.pauseRecording();
|
|
258
|
+
expect(dummyContact.pauseRecording).toHaveBeenCalledWith({interactionId: 'int1'});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('resumeRecording() with no payload defaults to autoResumed false', async () => {
|
|
262
|
+
const taskData = createBaseData();
|
|
263
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
264
|
+
isEndTaskEnabled: true,
|
|
265
|
+
isEndConsultEnabled: true,
|
|
266
|
+
});
|
|
267
|
+
primeConnectedState(voice, taskData);
|
|
268
|
+
voice.stateMachineService?.send({type: TaskEvent.PAUSE_RECORDING});
|
|
269
|
+
const res = await voice.resumeRecording();
|
|
270
|
+
expect(dummyContact.resumeRecording).toHaveBeenCalledWith({
|
|
271
|
+
interactionId: 'int1',
|
|
272
|
+
data: {autoResumed: false},
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('consult() calls contact.consult with payload', async () => {
|
|
277
|
+
const taskData = createBaseData();
|
|
278
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
279
|
+
isEndTaskEnabled: true,
|
|
280
|
+
isEndConsultEnabled: true,
|
|
281
|
+
});
|
|
282
|
+
primeConnectedState(voice, taskData);
|
|
283
|
+
const payload = {destination: 'agent1', destinationType: 'agent'} as any;
|
|
284
|
+
const res = await voice.consult(payload);
|
|
285
|
+
expect(dummyContact.consult).toHaveBeenCalledWith({
|
|
286
|
+
interactionId: 'int1',
|
|
287
|
+
data: payload,
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('transfer()', () => {
|
|
292
|
+
it('calls contact.consultTransfer for consult transfer to agent', async () => {
|
|
293
|
+
const consultTransferMock = jest.fn().mockResolvedValue('consultedA');
|
|
294
|
+
const dataWithState = createBaseData({
|
|
295
|
+
interaction: {state: 'consulting'} as any,
|
|
296
|
+
});
|
|
297
|
+
const voice = new Voice(
|
|
298
|
+
{...dummyContact, consultTransfer: consultTransferMock},
|
|
299
|
+
dataWithState as any,
|
|
300
|
+
{isEndTaskEnabled: true, isEndConsultEnabled: true}
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const result = await voice.transfer({
|
|
304
|
+
to: 'destB',
|
|
305
|
+
destinationType: 'agent',
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(consultTransferMock).toHaveBeenCalledWith({
|
|
309
|
+
interactionId: 'int1',
|
|
310
|
+
data: {to: 'destB', destinationType: 'agent'},
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('throws if consult transfer to QUEUE but no destAgentId set', async () => {
|
|
315
|
+
const dataWithState = createBaseData({
|
|
316
|
+
destAgentId: undefined,
|
|
317
|
+
interaction: {state: 'consulting'} as any,
|
|
318
|
+
});
|
|
319
|
+
const voice = new Voice(dummyContact, dataWithState as any, {
|
|
320
|
+
isEndTaskEnabled: true,
|
|
321
|
+
isEndConsultEnabled: true,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await expect(
|
|
325
|
+
voice.transfer({
|
|
326
|
+
to: 'queue1',
|
|
327
|
+
destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
|
|
328
|
+
})
|
|
329
|
+
).rejects.toThrow('No agent has accepted this queue consult yet');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('uses data.destAgentId for queue consult transfer', async () => {
|
|
333
|
+
const consultTransferMock = jest.fn().mockResolvedValue('consultedQ');
|
|
334
|
+
const dataWithDest = createBaseData({
|
|
335
|
+
destAgentId: 'agentD',
|
|
336
|
+
interaction: {state: 'consulting'} as any,
|
|
337
|
+
});
|
|
338
|
+
const voice = new Voice(
|
|
339
|
+
{...dummyContact, consultTransfer: consultTransferMock},
|
|
340
|
+
dataWithDest as any,
|
|
341
|
+
{isEndTaskEnabled: true, isEndConsultEnabled: true}
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const result = await voice.transfer({
|
|
345
|
+
to: 'queueX',
|
|
346
|
+
destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(consultTransferMock).toHaveBeenCalledWith({
|
|
350
|
+
interactionId: 'int1',
|
|
351
|
+
data: {
|
|
352
|
+
to: 'agentD',
|
|
353
|
+
destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('uses preserved consult destination from task data for queue consult transfer', async () => {
|
|
359
|
+
const consultTransferMock = jest.fn().mockResolvedValue('consultedQ');
|
|
360
|
+
const dataWithState = createBaseData({
|
|
361
|
+
destAgentId: undefined,
|
|
362
|
+
destinationType: undefined,
|
|
363
|
+
interaction: {state: 'consulting'} as any,
|
|
364
|
+
});
|
|
365
|
+
const voice = new Voice(
|
|
366
|
+
{...dummyContact, consultTransfer: consultTransferMock},
|
|
367
|
+
dataWithState as any,
|
|
368
|
+
{isEndTaskEnabled: true, isEndConsultEnabled: true}
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
primeConnectedState(voice, dataWithState);
|
|
372
|
+
voice.updateTaskData(
|
|
373
|
+
createBaseData({
|
|
374
|
+
destAgentId: 'agent-preserved',
|
|
375
|
+
interaction: {state: 'consulting'} as any,
|
|
376
|
+
}) as any
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
await voice.transfer({
|
|
380
|
+
to: 'queueX',
|
|
381
|
+
destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(consultTransferMock).toHaveBeenCalledWith({
|
|
385
|
+
interactionId: 'int1',
|
|
386
|
+
data: {
|
|
387
|
+
to: 'agent-preserved',
|
|
388
|
+
destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT,
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('endConsult()', () => {
|
|
395
|
+
it('calls contact.consultEnd with correct payload', async () => {
|
|
396
|
+
const consultEndMock = jest.fn().mockResolvedValue('endedC');
|
|
397
|
+
const voice = new Voice({...dummyContact, consultEnd: consultEndMock}, createBaseData(), {
|
|
398
|
+
isEndTaskEnabled: true,
|
|
399
|
+
isEndConsultEnabled: true,
|
|
400
|
+
});
|
|
401
|
+
const payload = {isConsult: true, queueId: 'q1', taskId: 't1'};
|
|
402
|
+
const result = await voice.endConsult(payload);
|
|
403
|
+
|
|
404
|
+
expect(consultEndMock).toHaveBeenCalledWith({
|
|
405
|
+
interactionId: 'int1',
|
|
406
|
+
data: payload,
|
|
407
|
+
});
|
|
408
|
+
expect(result).toBe('endedC');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('uses mainInteractionId when present for consultEnd request', async () => {
|
|
412
|
+
const consultEndMock = jest.fn().mockResolvedValue('endedC');
|
|
413
|
+
const taskData = createBaseData({
|
|
414
|
+
interactionId: 'child-int',
|
|
415
|
+
interaction: {
|
|
416
|
+
mainInteractionId: 'main-int',
|
|
417
|
+
} as any,
|
|
418
|
+
});
|
|
419
|
+
const voice = new Voice({...dummyContact, consultEnd: consultEndMock}, taskData, {
|
|
420
|
+
isEndTaskEnabled: true,
|
|
421
|
+
isEndConsultEnabled: true,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
await voice.endConsult({isConsult: true} as any);
|
|
425
|
+
|
|
426
|
+
expect(consultEndMock).toHaveBeenCalledWith({
|
|
427
|
+
interactionId: 'main-int',
|
|
428
|
+
data: {isConsult: true},
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
describe('UI controls for AGENT_CONTACT_ASSIGNED', () => {
|
|
434
|
+
it('shows main controls and hides accept/decline on AGENT_CONTACT_ASSIGNED', () => {
|
|
435
|
+
const data: any = {...createBaseData(), type: CC_EVENTS.AGENT_CONTACT_ASSIGNED};
|
|
436
|
+
const voice = new Voice(dummyContact, data, {
|
|
437
|
+
isEndTaskEnabled: true,
|
|
438
|
+
isEndConsultEnabled: false,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
voice.updateTaskData(data);
|
|
442
|
+
primeConnectedState(voice, data);
|
|
443
|
+
|
|
444
|
+
expect(voice.uiControls.main.accept.isVisible).toBe(false);
|
|
445
|
+
expect(voice.uiControls.main.decline.isVisible).toBe(false);
|
|
446
|
+
expect(voice.uiControls.main.hold.isVisible).toBe(true);
|
|
447
|
+
expect(voice.uiControls.main.transfer.isVisible).toBe(true);
|
|
448
|
+
expect(voice.uiControls.main.consult.isVisible).toBe(true);
|
|
449
|
+
expect(voice.uiControls.main.recording.isVisible).toBe(true);
|
|
450
|
+
expect(voice.uiControls.main.end.isVisible).toBe(true);
|
|
451
|
+
expect(voice.uiControls.main.endConsult.isVisible).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe('state machine derived controls', () => {
|
|
456
|
+
it('keeps uiControls in sync with state machine context', () => {
|
|
457
|
+
const taskData = createBaseData();
|
|
458
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
459
|
+
const initialSnapshot = voice.stateMachineService?.getSnapshot();
|
|
460
|
+
const initialExpected = computeUIControls(
|
|
461
|
+
initialSnapshot?.value as TaskState,
|
|
462
|
+
initialSnapshot?.context as any,
|
|
463
|
+
voice.data
|
|
464
|
+
);
|
|
465
|
+
expect(voice.uiControls).toEqual(initialExpected);
|
|
466
|
+
|
|
467
|
+
voice.updateTaskData(taskData);
|
|
468
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
469
|
+
voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
|
|
470
|
+
|
|
471
|
+
const snapshot = voice.stateMachineService?.getSnapshot();
|
|
472
|
+
const expected = computeUIControls(
|
|
473
|
+
snapshot?.value as TaskState,
|
|
474
|
+
snapshot?.context as any,
|
|
475
|
+
voice.data
|
|
476
|
+
);
|
|
477
|
+
expect(voice.uiControls).toEqual(expected);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe('recording operations', () => {
|
|
482
|
+
const buildRecordingData = (recordingOverrides: Record<string, any>) =>
|
|
483
|
+
createBaseData({
|
|
484
|
+
interaction: {
|
|
485
|
+
callProcessingDetails: recordingOverrides,
|
|
486
|
+
} as any,
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('throws when pauseRecording is invoked without active recording', async () => {
|
|
490
|
+
const voice = new Voice(dummyContact, createBaseData(), {});
|
|
491
|
+
await expect(voice.pauseRecording()).rejects.toThrow(
|
|
492
|
+
'Recording is not active or already paused'
|
|
493
|
+
);
|
|
494
|
+
expect(dummyContact.pauseRecording).not.toHaveBeenCalled();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('pauses recording when state machine context indicates active recording', async () => {
|
|
498
|
+
const taskData = buildRecordingData({recordInProgress: true});
|
|
499
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
500
|
+
primeConnectedState(voice, taskData);
|
|
501
|
+
|
|
502
|
+
await voice.pauseRecording();
|
|
503
|
+
expect(dummyContact.pauseRecording).toHaveBeenCalledWith({interactionId: 'int1'});
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it('throws if resumeRecording is invoked while recording is not paused', async () => {
|
|
507
|
+
const taskData = buildRecordingData({recordInProgress: true});
|
|
508
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
509
|
+
primeConnectedState(voice, taskData);
|
|
510
|
+
|
|
511
|
+
await expect(voice.resumeRecording()).rejects.toThrow('Recording is not paused');
|
|
512
|
+
expect(dummyContact.resumeRecording).not.toHaveBeenCalled();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('resumes recording when context shows paused recording', async () => {
|
|
516
|
+
const taskData = buildRecordingData({recordInProgress: true});
|
|
517
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
518
|
+
primeConnectedState(voice, taskData);
|
|
519
|
+
voice.stateMachineService?.send({type: TaskEvent.PAUSE_RECORDING});
|
|
520
|
+
|
|
521
|
+
await voice.resumeRecording();
|
|
522
|
+
expect(dummyContact.resumeRecording).toHaveBeenCalledWith({
|
|
523
|
+
interactionId: 'int1',
|
|
524
|
+
data: {autoResumed: false},
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe('switchCall()', () => {
|
|
530
|
+
const buildConsultingTaskData = () =>
|
|
531
|
+
createBaseData({
|
|
532
|
+
agentId: 'agent1',
|
|
533
|
+
consultMediaResourceId: 'consultMedia1',
|
|
534
|
+
interaction: {
|
|
535
|
+
media: {
|
|
536
|
+
media1: {
|
|
537
|
+
mediaResourceId: 'media1',
|
|
538
|
+
isHold: true,
|
|
539
|
+
mType: 'mainCall',
|
|
540
|
+
participants: ['agent1', 'customer1'],
|
|
541
|
+
},
|
|
542
|
+
consultMedia1: {
|
|
543
|
+
mediaResourceId: 'consultMedia1',
|
|
544
|
+
isHold: false,
|
|
545
|
+
mType: 'consult',
|
|
546
|
+
participants: ['agent1', 'agent2'],
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
participants: {
|
|
550
|
+
agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
|
|
551
|
+
agent2: {id: 'agent2', pType: 'Agent', type: 'Agent', hasLeft: false},
|
|
552
|
+
customer1: {id: 'customer1', pType: 'Customer', type: 'Customer', hasLeft: false},
|
|
553
|
+
},
|
|
554
|
+
} as any,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const primeConsultingState = (voice: Voice, taskData: TaskData) => {
|
|
558
|
+
primeConnectedState(voice, taskData);
|
|
559
|
+
voice.stateMachineService?.send({
|
|
560
|
+
type: TaskEvent.CONSULT,
|
|
561
|
+
destination: 'agent2',
|
|
562
|
+
destinationType: 'agent' as any,
|
|
563
|
+
});
|
|
564
|
+
voice.stateMachineService?.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
it('switches from consult leg to main leg by unholding main media', async () => {
|
|
568
|
+
const taskData = buildConsultingTaskData();
|
|
569
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
570
|
+
primeConsultingState(voice, taskData);
|
|
571
|
+
|
|
572
|
+
await voice.switchCall();
|
|
573
|
+
|
|
574
|
+
expect(dummyContact.unHold).toHaveBeenCalledTimes(1);
|
|
575
|
+
expect(dummyContact.unHold).toHaveBeenCalledWith({
|
|
576
|
+
interactionId: 'int1',
|
|
577
|
+
data: {mediaResourceId: 'media1'},
|
|
578
|
+
});
|
|
579
|
+
expect(dummyContact.hold).not.toHaveBeenCalled();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('switches from main leg to consult leg by holding main media', async () => {
|
|
583
|
+
const taskData = buildConsultingTaskData();
|
|
584
|
+
taskData.interaction.media.media1.isHold = false;
|
|
585
|
+
taskData.interaction.media.consultMedia1.isHold = true;
|
|
586
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
587
|
+
primeConsultingState(voice, taskData);
|
|
588
|
+
voice.stateMachineService?.send({type: TaskEvent.SWITCH_TO_MAIN_CALL});
|
|
589
|
+
|
|
590
|
+
await voice.switchCall();
|
|
591
|
+
|
|
592
|
+
expect(dummyContact.hold).toHaveBeenCalledTimes(1);
|
|
593
|
+
expect(dummyContact.hold).toHaveBeenCalledWith({
|
|
594
|
+
interactionId: 'int1',
|
|
595
|
+
data: {mediaResourceId: 'media1'},
|
|
596
|
+
});
|
|
597
|
+
expect(dummyContact.unHold).not.toHaveBeenCalled();
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe('exitConference()', () => {
|
|
602
|
+
const conferenceTaskData = () =>
|
|
603
|
+
createBaseData({
|
|
604
|
+
agentId: 'agent-1',
|
|
605
|
+
interaction: {
|
|
606
|
+
state: 'conference',
|
|
607
|
+
owner: 'agent-1',
|
|
608
|
+
mainInteractionId: 'int1',
|
|
609
|
+
interactionId: 'int1',
|
|
610
|
+
participants: {
|
|
611
|
+
'agent-1': {id: 'agent-1', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
|
|
612
|
+
'agent-2': {id: 'agent-2', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
|
|
613
|
+
c1: {id: 'c1', pType: 'Customer', type: 'Customer', hasJoined: true, hasLeft: false},
|
|
614
|
+
},
|
|
615
|
+
media: {
|
|
616
|
+
media1: {mediaResourceId: 'media1', isHold: false},
|
|
617
|
+
int1: {
|
|
618
|
+
mediaResourceId: 'int1',
|
|
619
|
+
mType: 'mainCall',
|
|
620
|
+
participants: ['agent-1', 'agent-2', 'c1'],
|
|
621
|
+
isHold: false,
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
} as any,
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const primeConferencing = (voice: Voice, taskData: TaskData) => {
|
|
628
|
+
primeConnectedState(voice, taskData);
|
|
629
|
+
voice.stateMachineService?.send({type: TaskEvent.CONFERENCE_START, taskData});
|
|
630
|
+
expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.CONFERENCING);
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
it('transitions to WRAPPING_UP after exitConference when the actor stays CONNECTED', async () => {
|
|
634
|
+
const taskData = conferenceTaskData();
|
|
635
|
+
const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-1');
|
|
636
|
+
primeConnectedState(voice, taskData);
|
|
637
|
+
expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.CONNECTED);
|
|
638
|
+
|
|
639
|
+
await voice.exitConference();
|
|
640
|
+
|
|
641
|
+
expect(dummyContact.exitConference).toHaveBeenCalled();
|
|
642
|
+
expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('stamps wrapUpRequired on EXIT_CONFERENCE_SUCCESS when this agent should wrap', async () => {
|
|
646
|
+
const taskData = conferenceTaskData();
|
|
647
|
+
const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-1');
|
|
648
|
+
primeConferencing(voice, taskData);
|
|
649
|
+
const sendSpy = jest.spyOn(voice.stateMachineService as any, 'send');
|
|
650
|
+
|
|
651
|
+
await voice.exitConference();
|
|
652
|
+
|
|
653
|
+
const successEvent = sendSpy.mock.calls
|
|
654
|
+
.map((call) => call[0])
|
|
655
|
+
.find((event) => event?.type === TaskEvent.EXIT_CONFERENCE_SUCCESS);
|
|
656
|
+
|
|
657
|
+
expect(dummyContact.exitConference).toHaveBeenCalled();
|
|
658
|
+
expect(successEvent?.taskData.wrapUpRequired).toBe(true);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('stamps wrapUpRequired false on EXIT_CONFERENCE_SUCCESS when this agent should not wrap', async () => {
|
|
662
|
+
const taskData = createBaseData({
|
|
663
|
+
agentId: 'agent-2',
|
|
664
|
+
isConsulted: true,
|
|
665
|
+
wrapUpRequired: false,
|
|
666
|
+
interaction: {
|
|
667
|
+
state: 'conference',
|
|
668
|
+
owner: 'agent-1',
|
|
669
|
+
mainInteractionId: 'int1',
|
|
670
|
+
interactionId: 'int1',
|
|
671
|
+
participants: {
|
|
672
|
+
'agent-1': {id: 'agent-1', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
|
|
673
|
+
'agent-2': {
|
|
674
|
+
id: 'agent-2',
|
|
675
|
+
pType: 'Agent',
|
|
676
|
+
type: 'Agent',
|
|
677
|
+
hasJoined: true,
|
|
678
|
+
hasLeft: false,
|
|
679
|
+
isWrapUp: false,
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
} as any,
|
|
683
|
+
});
|
|
684
|
+
const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-2');
|
|
685
|
+
primeConferencing(voice, taskData);
|
|
686
|
+
const sendSpy = jest.spyOn(voice.stateMachineService as any, 'send');
|
|
687
|
+
|
|
688
|
+
await voice.exitConference();
|
|
689
|
+
|
|
690
|
+
const successEvent = sendSpy.mock.calls
|
|
691
|
+
.map((call) => call[0])
|
|
692
|
+
.find((event) => event?.type === TaskEvent.EXIT_CONFERENCE_SUCCESS);
|
|
693
|
+
|
|
694
|
+
expect(successEvent?.taskData.wrapUpRequired).toBe(false);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it('stamps wrapUpRequired true on owner-changed exit when isConsulted is omitted', async () => {
|
|
698
|
+
const taskData = createBaseData({
|
|
699
|
+
agentId: 'agent-1',
|
|
700
|
+
wrapUpRequired: false,
|
|
701
|
+
interaction: {
|
|
702
|
+
state: 'conference',
|
|
703
|
+
owner: 'agent-2',
|
|
704
|
+
mainInteractionId: 'int1',
|
|
705
|
+
interactionId: 'int1',
|
|
706
|
+
participants: {
|
|
707
|
+
'agent-1': {id: 'agent-1', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
|
|
708
|
+
'agent-2': {id: 'agent-2', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
|
|
709
|
+
},
|
|
710
|
+
} as any,
|
|
711
|
+
});
|
|
712
|
+
const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-1');
|
|
713
|
+
primeConferencing(voice, taskData);
|
|
714
|
+
const sendSpy = jest.spyOn(voice.stateMachineService as any, 'send');
|
|
715
|
+
|
|
716
|
+
await voice.exitConference();
|
|
717
|
+
|
|
718
|
+
const successEvent = sendSpy.mock.calls
|
|
719
|
+
.map((call) => call[0])
|
|
720
|
+
.find((event) => event?.type === TaskEvent.EXIT_CONFERENCE_SUCCESS);
|
|
721
|
+
|
|
722
|
+
expect(successEvent?.taskData.wrapUpRequired).toBe(true);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('ignores a stale cached pending list when the exit response omits it', async () => {
|
|
726
|
+
const taskData = createBaseData({
|
|
727
|
+
agentId: 'agent-1',
|
|
728
|
+
wrapUpRequired: false,
|
|
729
|
+
agentsPendingWrapUp: ['agent-2'],
|
|
730
|
+
interaction: {
|
|
731
|
+
state: 'conference',
|
|
732
|
+
owner: 'agent-2',
|
|
733
|
+
mainInteractionId: 'int1',
|
|
734
|
+
interactionId: 'int1',
|
|
735
|
+
participants: {
|
|
736
|
+
'agent-1': {id: 'agent-1', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
|
|
737
|
+
'agent-2': {id: 'agent-2', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
|
|
738
|
+
},
|
|
739
|
+
} as any,
|
|
740
|
+
});
|
|
741
|
+
const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-1');
|
|
742
|
+
primeConferencing(voice, taskData);
|
|
743
|
+
const sendSpy = jest.spyOn(voice.stateMachineService as any, 'send');
|
|
744
|
+
|
|
745
|
+
await voice.exitConference();
|
|
746
|
+
|
|
747
|
+
const successEvent = sendSpy.mock.calls
|
|
748
|
+
.map((call) => call[0])
|
|
749
|
+
.find((event) => event?.type === TaskEvent.EXIT_CONFERENCE_SUCCESS);
|
|
750
|
+
|
|
751
|
+
expect(successEvent?.taskData.wrapUpRequired).toBe(true);
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
describe('consultConference()', () => {
|
|
756
|
+
afterEach(() => {
|
|
757
|
+
jest.restoreAllMocks();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('uses cached consult destination when task data destination is cleared', async () => {
|
|
761
|
+
const taskData = createBaseData({
|
|
762
|
+
agentId: 'agent1',
|
|
763
|
+
destAgentId: undefined,
|
|
764
|
+
destinationType: undefined,
|
|
765
|
+
interaction: {
|
|
766
|
+
media: {
|
|
767
|
+
media1: {mediaResourceId: 'media1', isHold: true},
|
|
768
|
+
},
|
|
769
|
+
participants: {
|
|
770
|
+
agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
|
|
771
|
+
},
|
|
772
|
+
} as any,
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
776
|
+
primeConnectedState(voice, taskData);
|
|
777
|
+
voice.stateMachineService?.send({
|
|
778
|
+
type: TaskEvent.CONSULT,
|
|
779
|
+
destination: 'agent2',
|
|
780
|
+
destAgentId: 'agent2',
|
|
781
|
+
destinationType: 'agent' as any,
|
|
782
|
+
});
|
|
783
|
+
voice.updateTaskData(
|
|
784
|
+
createBaseData({
|
|
785
|
+
agentId: 'agent1',
|
|
786
|
+
destAgentId: undefined,
|
|
787
|
+
destinationType: undefined,
|
|
788
|
+
interaction: {state: 'consulting'} as any,
|
|
789
|
+
}) as any
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
await voice.consultConference();
|
|
793
|
+
|
|
794
|
+
expect(dummyContact.consultConference).toHaveBeenCalledWith({
|
|
795
|
+
interactionId: 'int1',
|
|
796
|
+
data: expect.objectContaining({
|
|
797
|
+
to: 'agent2',
|
|
798
|
+
destinationType: 'agent',
|
|
799
|
+
}),
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('falls back to derived destination when context and task destination are unavailable', async () => {
|
|
804
|
+
jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValueOnce('derivedAgent');
|
|
805
|
+
jest.spyOn(Utils, 'calculateDestType').mockReturnValueOnce('agent');
|
|
806
|
+
|
|
807
|
+
const taskData = createBaseData({
|
|
808
|
+
agentId: 'agent1',
|
|
809
|
+
destAgentId: undefined,
|
|
810
|
+
destinationType: undefined,
|
|
811
|
+
interaction: {
|
|
812
|
+
media: {
|
|
813
|
+
media1: {mediaResourceId: 'media1', isHold: true},
|
|
814
|
+
},
|
|
815
|
+
participants: {
|
|
816
|
+
agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
|
|
817
|
+
},
|
|
818
|
+
} as any,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
822
|
+
primeConnectedState(voice, taskData);
|
|
823
|
+
voice.stateMachineService?.send({
|
|
824
|
+
type: TaskEvent.CONSULT,
|
|
825
|
+
destination: '',
|
|
826
|
+
destAgentId: undefined,
|
|
827
|
+
destinationType: 'agent' as any,
|
|
828
|
+
});
|
|
829
|
+
voice.stateMachineService?.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
|
|
830
|
+
|
|
831
|
+
await voice.consultConference();
|
|
832
|
+
|
|
833
|
+
expect(dummyContact.consultConference).toHaveBeenCalledWith({
|
|
834
|
+
interactionId: 'int1',
|
|
835
|
+
data: expect.objectContaining({
|
|
836
|
+
to: 'derivedAgent',
|
|
837
|
+
}),
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
describe('dropConferenceParticipant()', () => {
|
|
843
|
+
afterEach(() => {
|
|
844
|
+
jest.restoreAllMocks();
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it.each([
|
|
848
|
+
['missing payload', undefined],
|
|
849
|
+
['missing participantId', {}],
|
|
850
|
+
['non-string participantId', {participantId: 42}],
|
|
851
|
+
['blank participantId', {participantId: ' '}],
|
|
852
|
+
])('rejects %s before registering a request', async (_label, payload) => {
|
|
853
|
+
const taskData = createBaseData();
|
|
854
|
+
const voice = new Voice(dummyContact, taskData, {});
|
|
855
|
+
const timeEventSpy = jest.spyOn(MetricsManager.getInstance(), 'timeEvent');
|
|
856
|
+
|
|
857
|
+
await expect(voice.dropConferenceParticipant(payload as any)).rejects.toThrow(
|
|
858
|
+
'participantId must be a non-empty string'
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
expect(dummyContact.dropConferenceParticipant).not.toHaveBeenCalled();
|
|
862
|
+
expect(timeEventSpy).not.toHaveBeenCalled();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
it('uses the latest main interaction ID and records PII-safe success telemetry', async () => {
|
|
866
|
+
const participantId = '+1/participant-secret';
|
|
867
|
+
const taskData = createBaseData({
|
|
868
|
+
interaction: {mainInteractionId: 'main-interaction-id'} as any,
|
|
869
|
+
});
|
|
870
|
+
const voice = new Voice(dummyContact, taskData, {}, undefined, 'current-agent-id');
|
|
871
|
+
const metricsManager = MetricsManager.getInstance();
|
|
872
|
+
const timeEventSpy = jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
|
|
873
|
+
const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
|
|
874
|
+
const infoSpy = jest.spyOn(LoggerProxy, 'info').mockImplementation();
|
|
875
|
+
const logSpy = jest.spyOn(LoggerProxy, 'log').mockImplementation();
|
|
876
|
+
|
|
877
|
+
await voice.dropConferenceParticipant({participantId});
|
|
878
|
+
|
|
879
|
+
expect(dummyContact.dropConferenceParticipant).toHaveBeenCalledWith({
|
|
880
|
+
interactionId: 'main-interaction-id',
|
|
881
|
+
participantId,
|
|
882
|
+
});
|
|
883
|
+
expect(timeEventSpy).toHaveBeenCalledWith([
|
|
884
|
+
METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_SUCCESS,
|
|
885
|
+
METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
|
|
886
|
+
]);
|
|
887
|
+
expect(trackEventSpy).toHaveBeenCalledWith(
|
|
888
|
+
METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_SUCCESS,
|
|
889
|
+
expect.objectContaining({
|
|
890
|
+
taskId: 'int1',
|
|
891
|
+
requestInteractionId: 'main-interaction-id',
|
|
892
|
+
agentId: 'current-agent-id',
|
|
893
|
+
}),
|
|
894
|
+
['operational', 'behavioral', 'business']
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
const telemetryAndLogs = JSON.stringify([
|
|
898
|
+
timeEventSpy.mock.calls,
|
|
899
|
+
trackEventSpy.mock.calls,
|
|
900
|
+
infoSpy.mock.calls,
|
|
901
|
+
logSpy.mock.calls,
|
|
902
|
+
]);
|
|
903
|
+
expect(telemetryAndLogs).not.toContain(participantId);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
it('records PII-safe failure telemetry and rethrows the AQM error', async () => {
|
|
907
|
+
const participantId = '+1/failure-secret';
|
|
908
|
+
const failure = {
|
|
909
|
+
details: {
|
|
910
|
+
type: 'ParticipantDropConferenceFailed',
|
|
911
|
+
trackingId: 'failure-tracking-id',
|
|
912
|
+
data: {reason: participantId},
|
|
913
|
+
},
|
|
914
|
+
};
|
|
915
|
+
dummyContact.dropConferenceParticipant.mockRejectedValueOnce(failure);
|
|
916
|
+
const taskData = createBaseData({
|
|
917
|
+
interaction: {mainInteractionId: 'main-interaction-id'} as any,
|
|
918
|
+
});
|
|
919
|
+
const voice = new Voice(dummyContact, taskData, {}, undefined, 'current-agent-id');
|
|
920
|
+
const metricsManager = MetricsManager.getInstance();
|
|
921
|
+
jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
|
|
922
|
+
const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
|
|
923
|
+
const errorSpy = jest.spyOn(LoggerProxy, 'error').mockImplementation();
|
|
924
|
+
|
|
925
|
+
await expect(voice.dropConferenceParticipant({participantId})).rejects.toBe(failure);
|
|
926
|
+
|
|
927
|
+
expect(trackEventSpy).toHaveBeenCalledWith(
|
|
928
|
+
METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
|
|
929
|
+
expect.objectContaining({
|
|
930
|
+
taskId: 'int1',
|
|
931
|
+
requestInteractionId: 'main-interaction-id',
|
|
932
|
+
agentId: 'current-agent-id',
|
|
933
|
+
}),
|
|
934
|
+
['operational', 'behavioral', 'business']
|
|
935
|
+
);
|
|
936
|
+
expect(errorSpy).toHaveBeenCalledWith('Failed to drop conference participant', {
|
|
937
|
+
module: 'cc',
|
|
938
|
+
method: 'dropConferenceParticipant',
|
|
939
|
+
trackingId: 'failure-tracking-id',
|
|
940
|
+
interactionId: 'main-interaction-id',
|
|
941
|
+
});
|
|
942
|
+
expect(JSON.stringify([trackEventSpy.mock.calls, errorSpy.mock.calls])).not.toContain(
|
|
943
|
+
participantId
|
|
944
|
+
);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
it.each([
|
|
948
|
+
['null', null],
|
|
949
|
+
['undefined', undefined],
|
|
950
|
+
['string', 'primitive failure'],
|
|
951
|
+
['number', 503],
|
|
952
|
+
])('records failure telemetry and preserves a %s rejection', async (_label, failure) => {
|
|
953
|
+
dummyContact.dropConferenceParticipant.mockRejectedValueOnce(failure);
|
|
954
|
+
const voice = new Voice(
|
|
955
|
+
dummyContact,
|
|
956
|
+
createBaseData(),
|
|
957
|
+
{},
|
|
958
|
+
undefined,
|
|
959
|
+
'current-agent-id'
|
|
960
|
+
);
|
|
961
|
+
const metricsManager = MetricsManager.getInstance();
|
|
962
|
+
const failureFieldsSpy = jest.spyOn(
|
|
963
|
+
MetricsManager,
|
|
964
|
+
'getCommonTrackingFieldForAQMResponseFailed'
|
|
965
|
+
);
|
|
966
|
+
jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
|
|
967
|
+
const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
|
|
968
|
+
const errorSpy = jest.spyOn(LoggerProxy, 'error').mockImplementation();
|
|
969
|
+
|
|
970
|
+
await expect(
|
|
971
|
+
voice.dropConferenceParticipant({participantId: 'participant-id'})
|
|
972
|
+
).rejects.toBe(failure);
|
|
973
|
+
|
|
974
|
+
expect(failureFieldsSpy).toHaveBeenCalledWith({});
|
|
975
|
+
expect(trackEventSpy).toHaveBeenCalledWith(
|
|
976
|
+
METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
|
|
977
|
+
expect.objectContaining({
|
|
978
|
+
taskId: 'int1',
|
|
979
|
+
requestInteractionId: 'int1',
|
|
980
|
+
agentId: 'current-agent-id',
|
|
981
|
+
}),
|
|
982
|
+
['operational', 'behavioral', 'business']
|
|
983
|
+
);
|
|
984
|
+
expect(errorSpy).toHaveBeenCalledWith('Failed to drop conference participant', {
|
|
985
|
+
module: 'cc',
|
|
986
|
+
method: 'dropConferenceParticipant',
|
|
987
|
+
trackingId: undefined,
|
|
988
|
+
interactionId: 'int1',
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
describe('setEnableWxBetterTogether', () => {
|
|
994
|
+
it('updates runtime flag, uiControlConfig, and emits ui control updates', () => {
|
|
995
|
+
const voice = new Voice(dummyContact, createBaseData(), {enableWxBetterTogether: true});
|
|
996
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
997
|
+
|
|
998
|
+
voice.setEnableWxBetterTogether(false);
|
|
999
|
+
|
|
1000
|
+
expect(voice['enableWxBetterTogether']).toBe(false);
|
|
1001
|
+
expect(voice['uiControlConfig'].enableWxBetterTogether).toBe(false);
|
|
1002
|
+
expect(emitSpy).toHaveBeenCalledWith(
|
|
1003
|
+
TASK_EVENTS.TASK_UI_CONTROLS_UPDATED,
|
|
1004
|
+
expect.any(Object)
|
|
1005
|
+
);
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
describe('wxApp offer decision logging', () => {
|
|
1010
|
+
const wxAppParticipant = {
|
|
1011
|
+
deviceType: 'wxApp',
|
|
1012
|
+
deviceId: 'device-id-1',
|
|
1013
|
+
deviceCallId: 'call-id-1',
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const makeWxAppOfferTaskData = () =>
|
|
1017
|
+
createBaseData({
|
|
1018
|
+
agentId: 'agent-1',
|
|
1019
|
+
interaction: {
|
|
1020
|
+
participants: {
|
|
1021
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1022
|
+
},
|
|
1023
|
+
} as any,
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it('logs evolving accept reasons through the normal wxApp accept flow', () => {
|
|
1027
|
+
const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
|
|
1028
|
+
const taskData = makeWxAppOfferTaskData();
|
|
1029
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
|
|
1030
|
+
|
|
1031
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1032
|
+
|
|
1033
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
1034
|
+
expect.objectContaining({acceptReason: 'wxApp_offer_ready'})
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
logSpy.mockClear();
|
|
1038
|
+
voice['setWxAppAcceptInFlight'](true);
|
|
1039
|
+
|
|
1040
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
1041
|
+
expect.objectContaining({acceptReason: 'wxApp_accept_in_flight'})
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
logSpy.mockClear();
|
|
1045
|
+
voice['setWxAppAcceptInFlight'](false);
|
|
1046
|
+
voice['setWxAppAnswerPending'](true);
|
|
1047
|
+
|
|
1048
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
1049
|
+
expect.objectContaining({acceptReason: 'wxApp_answer_pending'})
|
|
1050
|
+
);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('logs browser_webrtc_offer when voice variant is WebRTC on a non-wxApp inbound offer', () => {
|
|
1054
|
+
const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
|
|
1055
|
+
const taskData = createBaseData({
|
|
1056
|
+
agentId: 'agent-1',
|
|
1057
|
+
interaction: {
|
|
1058
|
+
participants: {
|
|
1059
|
+
'agent-1': {id: 'agent-1', deviceType: 'phone', deviceId: 'device-id-1'},
|
|
1060
|
+
},
|
|
1061
|
+
} as any,
|
|
1062
|
+
});
|
|
1063
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1064
|
+
enableWxBetterTogether: true,
|
|
1065
|
+
voiceVariant: VOICE_VARIANT.WEBRTC,
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1069
|
+
|
|
1070
|
+
expect(logSpy).toHaveBeenCalledWith(
|
|
1071
|
+
expect.objectContaining({acceptReason: 'browser_webrtc_offer'})
|
|
1072
|
+
);
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
describe('applyWxAppMuteStateFromSync', () => {
|
|
1077
|
+
const wxAppParticipant = {
|
|
1078
|
+
deviceType: 'wxApp',
|
|
1079
|
+
deviceId: 'device-id-1',
|
|
1080
|
+
deviceCallId: 'call-id-1',
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
const makeWxAppTaskData = () =>
|
|
1084
|
+
createBaseData({
|
|
1085
|
+
agentId: 'agent-1',
|
|
1086
|
+
interaction: {
|
|
1087
|
+
participants: {
|
|
1088
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1089
|
+
},
|
|
1090
|
+
} as any,
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it('emits TASK_WXAPP_MUTE_STATE_UPDATED when callId matches via endsWith', () => {
|
|
1094
|
+
const taskData = makeWxAppTaskData();
|
|
1095
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
|
|
1096
|
+
primeConnectedState(voice, taskData);
|
|
1097
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
1098
|
+
|
|
1099
|
+
voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
|
|
1100
|
+
|
|
1101
|
+
expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {muted: true});
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it('no-ops when callId does not match active call', () => {
|
|
1105
|
+
const taskData = makeWxAppTaskData();
|
|
1106
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
|
|
1107
|
+
primeConnectedState(voice, taskData);
|
|
1108
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
1109
|
+
|
|
1110
|
+
voice.applyWxAppMuteStateFromSync('other-call-id', true);
|
|
1111
|
+
|
|
1112
|
+
expect(emitSpy).not.toHaveBeenCalledWith(
|
|
1113
|
+
TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
|
|
1114
|
+
expect.anything()
|
|
1115
|
+
);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
it('no-ops when enableWxBetterTogether is false', () => {
|
|
1119
|
+
const taskData = makeWxAppTaskData();
|
|
1120
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: false});
|
|
1121
|
+
primeConnectedState(voice, taskData);
|
|
1122
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
1123
|
+
|
|
1124
|
+
voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
|
|
1125
|
+
|
|
1126
|
+
expect(emitSpy).not.toHaveBeenCalledWith(
|
|
1127
|
+
TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
|
|
1128
|
+
expect.anything()
|
|
1129
|
+
);
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it('no-ops when muted state is unchanged', () => {
|
|
1133
|
+
const taskData = makeWxAppTaskData();
|
|
1134
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
|
|
1135
|
+
primeConnectedState(voice, taskData);
|
|
1136
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
1137
|
+
|
|
1138
|
+
voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
|
|
1139
|
+
emitSpy.mockClear();
|
|
1140
|
+
|
|
1141
|
+
voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
|
|
1142
|
+
|
|
1143
|
+
expect(emitSpy).not.toHaveBeenCalledWith(
|
|
1144
|
+
TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
|
|
1145
|
+
expect.anything()
|
|
1146
|
+
);
|
|
1147
|
+
});
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
describe('syncWxAppMuteFromCallDetails', () => {
|
|
1151
|
+
const wxAppParticipant = {
|
|
1152
|
+
deviceType: 'wxApp',
|
|
1153
|
+
deviceId: 'device-id-1',
|
|
1154
|
+
deviceCallId: 'call-id-1',
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
const makeWxAppTaskData = () =>
|
|
1158
|
+
createBaseData({
|
|
1159
|
+
agentId: 'agent-1',
|
|
1160
|
+
interaction: {
|
|
1161
|
+
participants: {
|
|
1162
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1163
|
+
},
|
|
1164
|
+
} as any,
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it('seeds mute state from telephony GET call details on engaged wxApp call', async () => {
|
|
1168
|
+
const mockSvc = {
|
|
1169
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: true}),
|
|
1170
|
+
};
|
|
1171
|
+
const taskData = makeWxAppTaskData();
|
|
1172
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1173
|
+
enableWxBetterTogether: true,
|
|
1174
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1175
|
+
});
|
|
1176
|
+
primeConnectedState(voice, taskData);
|
|
1177
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
1178
|
+
|
|
1179
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1180
|
+
|
|
1181
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
|
|
1182
|
+
expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
|
|
1183
|
+
muted: true,
|
|
1184
|
+
});
|
|
1185
|
+
expect(voice.getWxAppMuted()).toBe(true);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('returns resolved mute state from syncWxAppMuteFromCallDetails', async () => {
|
|
1189
|
+
const mockSvc = {
|
|
1190
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: true}),
|
|
1191
|
+
};
|
|
1192
|
+
const taskData = makeWxAppTaskData();
|
|
1193
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1194
|
+
enableWxBetterTogether: true,
|
|
1195
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1196
|
+
});
|
|
1197
|
+
primeConnectedState(voice, taskData);
|
|
1198
|
+
|
|
1199
|
+
const result = await voice.syncWxAppMuteFromCallDetails();
|
|
1200
|
+
|
|
1201
|
+
expect(result).toBe(true);
|
|
1202
|
+
expect(voice.getWxAppMuted()).toBe(true);
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it('seeds mute via participant deviceCallId when state machine is still IDLE', async () => {
|
|
1206
|
+
const mockSvc = {
|
|
1207
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: true}),
|
|
1208
|
+
};
|
|
1209
|
+
const taskData = makeWxAppTaskData();
|
|
1210
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1211
|
+
enableWxBetterTogether: true,
|
|
1212
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1213
|
+
});
|
|
1214
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
1215
|
+
|
|
1216
|
+
const result = await voice.syncWxAppMuteFromCallDetails();
|
|
1217
|
+
|
|
1218
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
|
|
1219
|
+
expect(result).toBe(true);
|
|
1220
|
+
expect(voice.getWxAppMuted()).toBe(true);
|
|
1221
|
+
expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
|
|
1222
|
+
muted: true,
|
|
1223
|
+
});
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
it('no-ops when enableWxBetterTogether is false', async () => {
|
|
1227
|
+
const mockSvc = {getCallDetails: jest.fn()};
|
|
1228
|
+
const taskData = makeWxAppTaskData();
|
|
1229
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1230
|
+
enableWxBetterTogether: false,
|
|
1231
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1232
|
+
});
|
|
1233
|
+
primeConnectedState(voice, taskData);
|
|
1234
|
+
|
|
1235
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1236
|
+
|
|
1237
|
+
expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
it('syncs mute state after task assignment once CONNECTED (onTaskAssigned)', async () => {
|
|
1241
|
+
const mockSvc = {
|
|
1242
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: true}),
|
|
1243
|
+
};
|
|
1244
|
+
const taskData = makeWxAppTaskData();
|
|
1245
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1246
|
+
enableWxBetterTogether: true,
|
|
1247
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1251
|
+
expect(voice.getWxAppMuted()).toBe(false);
|
|
1252
|
+
voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
|
|
1253
|
+
|
|
1254
|
+
await new Promise<void>((resolve) => {
|
|
1255
|
+
setImmediate(resolve);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
|
|
1259
|
+
expect(voice.getWxAppMuted()).toBe(true);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it('seeds mute on ASSIGN when wxApp offer was accepted on another session', async () => {
|
|
1263
|
+
const mockSvc = {
|
|
1264
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: false}),
|
|
1265
|
+
};
|
|
1266
|
+
const taskData = makeWxAppTaskData();
|
|
1267
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1268
|
+
enableWxBetterTogether: true,
|
|
1269
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1273
|
+
expect(voice['uiControlConfig']?.wxAppAnswerPending).toBeFalsy();
|
|
1274
|
+
voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
|
|
1275
|
+
|
|
1276
|
+
await new Promise<void>((resolve) => {
|
|
1277
|
+
setImmediate(resolve);
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it('syncs mute state after task hydrate (onTaskHydrated)', async () => {
|
|
1284
|
+
const mockSvc = {
|
|
1285
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: true}),
|
|
1286
|
+
};
|
|
1287
|
+
const taskData = makeWxAppTaskData();
|
|
1288
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1289
|
+
enableWxBetterTogether: true,
|
|
1290
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1291
|
+
});
|
|
1292
|
+
primeConnectedState(voice, taskData);
|
|
1293
|
+
const emitSpy = jest.spyOn(voice, 'emit');
|
|
1294
|
+
|
|
1295
|
+
voice.onTaskHydrated();
|
|
1296
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1297
|
+
|
|
1298
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
|
|
1299
|
+
expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
|
|
1300
|
+
muted: true,
|
|
1301
|
+
});
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it('preserves wxAppAnswerPending on hydrate while OFFERED and answer is pending', async () => {
|
|
1305
|
+
const mockSvc = {
|
|
1306
|
+
answerCall: jest.fn().mockResolvedValue(undefined),
|
|
1307
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: false}),
|
|
1308
|
+
};
|
|
1309
|
+
const taskData = makeWxAppTaskData();
|
|
1310
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1311
|
+
enableWxBetterTogether: true,
|
|
1312
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1313
|
+
});
|
|
1314
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1315
|
+
|
|
1316
|
+
await voice.accept();
|
|
1317
|
+
expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
|
|
1318
|
+
|
|
1319
|
+
voice.onTaskHydrated();
|
|
1320
|
+
|
|
1321
|
+
expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
it('clears wxAppAnswerPending on hydrate when task is no longer OFFERED', async () => {
|
|
1325
|
+
const mockSvc = {
|
|
1326
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: false}),
|
|
1327
|
+
};
|
|
1328
|
+
const taskData = makeWxAppTaskData();
|
|
1329
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1330
|
+
enableWxBetterTogether: true,
|
|
1331
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1332
|
+
});
|
|
1333
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1334
|
+
voice.uiControlConfig = {...voice.uiControlConfig, wxAppAnswerPending: true};
|
|
1335
|
+
primeConnectedState(voice, taskData);
|
|
1336
|
+
|
|
1337
|
+
voice.onTaskHydrated();
|
|
1338
|
+
|
|
1339
|
+
expect(voice.uiControlConfig.wxAppAnswerPending).toBe(false);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it('coalesces parallel syncWxAppMuteFromCallDetails into one getCallDetails', async () => {
|
|
1343
|
+
let resolveGetCallDetails: (value: {muted: boolean}) => void;
|
|
1344
|
+
const getCallDetailsPromise = new Promise<{muted: boolean}>((resolve) => {
|
|
1345
|
+
resolveGetCallDetails = resolve;
|
|
1346
|
+
});
|
|
1347
|
+
const mockSvc = {
|
|
1348
|
+
getCallDetails: jest.fn().mockReturnValue(getCallDetailsPromise),
|
|
1349
|
+
};
|
|
1350
|
+
const taskData = makeWxAppTaskData();
|
|
1351
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1352
|
+
enableWxBetterTogether: true,
|
|
1353
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1354
|
+
});
|
|
1355
|
+
primeConnectedState(voice, taskData);
|
|
1356
|
+
|
|
1357
|
+
const sync1 = voice.syncWxAppMuteFromCallDetails();
|
|
1358
|
+
const sync2 = voice.syncWxAppMuteFromCallDetails();
|
|
1359
|
+
|
|
1360
|
+
resolveGetCallDetails!({muted: true});
|
|
1361
|
+
const [result1, result2] = await Promise.all([sync1, sync2]);
|
|
1362
|
+
|
|
1363
|
+
expect(result1).toBe(true);
|
|
1364
|
+
expect(result2).toBe(true);
|
|
1365
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalledTimes(1);
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it('issues a new getCallDetails on sequential syncWxAppMuteFromCallDetails after the first completes', async () => {
|
|
1369
|
+
const mockSvc = {
|
|
1370
|
+
getCallDetails: jest
|
|
1371
|
+
.fn()
|
|
1372
|
+
.mockResolvedValueOnce({muted: false})
|
|
1373
|
+
.mockResolvedValueOnce({muted: true}),
|
|
1374
|
+
};
|
|
1375
|
+
const taskData = makeWxAppTaskData();
|
|
1376
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1377
|
+
enableWxBetterTogether: true,
|
|
1378
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1379
|
+
});
|
|
1380
|
+
primeConnectedState(voice, taskData);
|
|
1381
|
+
|
|
1382
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1383
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1384
|
+
|
|
1385
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalledTimes(2);
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
it('skips mute GET for terminated wxApp outdial with deviceCallId', async () => {
|
|
1389
|
+
const mockSvc = {getCallDetails: jest.fn()};
|
|
1390
|
+
const taskData = createBaseData({
|
|
1391
|
+
agentId: 'agent-1',
|
|
1392
|
+
interaction: {
|
|
1393
|
+
outboundType: 'OUTDIAL',
|
|
1394
|
+
isTerminated: true,
|
|
1395
|
+
participants: {
|
|
1396
|
+
'agent-1': {
|
|
1397
|
+
id: 'agent-1',
|
|
1398
|
+
deviceType: 'wxApp',
|
|
1399
|
+
deviceId: 'device-id-1',
|
|
1400
|
+
deviceCallId: 'callhalf-dead',
|
|
1401
|
+
},
|
|
1402
|
+
},
|
|
1403
|
+
} as any,
|
|
1404
|
+
});
|
|
1405
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1406
|
+
enableWxBetterTogether: true,
|
|
1407
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1408
|
+
});
|
|
1409
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1410
|
+
|
|
1411
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1412
|
+
|
|
1413
|
+
expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
it('skips mute GET for pre-accept wxApp OFFERED offer', async () => {
|
|
1417
|
+
const mockSvc = {getCallDetails: jest.fn()};
|
|
1418
|
+
const wxAppParticipant = {
|
|
1419
|
+
deviceType: 'wxApp',
|
|
1420
|
+
deviceId: 'device-id-1',
|
|
1421
|
+
deviceCallId: 'call-id-1',
|
|
1422
|
+
};
|
|
1423
|
+
const taskData = createBaseData({
|
|
1424
|
+
agentId: 'agent-1',
|
|
1425
|
+
interaction: {
|
|
1426
|
+
outboundType: 'OUTDIAL',
|
|
1427
|
+
participants: {
|
|
1428
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1429
|
+
},
|
|
1430
|
+
} as any,
|
|
1431
|
+
});
|
|
1432
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1433
|
+
enableWxBetterTogether: true,
|
|
1434
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1435
|
+
});
|
|
1436
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1437
|
+
|
|
1438
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1439
|
+
|
|
1440
|
+
expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
it('still calls mute GET for post-accept OFFERED wxApp offer', async () => {
|
|
1444
|
+
const mockSvc = {
|
|
1445
|
+
answerCall: jest.fn().mockResolvedValue(undefined),
|
|
1446
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: false}),
|
|
1447
|
+
};
|
|
1448
|
+
const wxAppParticipant = {
|
|
1449
|
+
deviceType: 'wxApp',
|
|
1450
|
+
deviceId: 'device-id-1',
|
|
1451
|
+
deviceCallId: 'call-id-1',
|
|
1452
|
+
};
|
|
1453
|
+
const taskData = createBaseData({
|
|
1454
|
+
agentId: 'agent-1',
|
|
1455
|
+
interaction: {
|
|
1456
|
+
outboundType: 'OUTDIAL',
|
|
1457
|
+
participants: {
|
|
1458
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1459
|
+
},
|
|
1460
|
+
} as any,
|
|
1461
|
+
});
|
|
1462
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1463
|
+
enableWxBetterTogether: true,
|
|
1464
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1465
|
+
});
|
|
1466
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1467
|
+
await voice.accept();
|
|
1468
|
+
|
|
1469
|
+
expect(voice.uiControlConfig.wxAppAcceptInFlight).toBe(false);
|
|
1470
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1471
|
+
|
|
1472
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
it('does not log error for expected wxApp call-not-found on mute sync', async () => {
|
|
1476
|
+
const errorSpy = jest.spyOn(LoggerProxy, 'error');
|
|
1477
|
+
const mockSvc = {
|
|
1478
|
+
getCallDetails: jest.fn().mockRejectedValue({status: 400, message: 'Call not found'}),
|
|
1479
|
+
};
|
|
1480
|
+
const wxAppParticipant = {
|
|
1481
|
+
deviceType: 'wxApp',
|
|
1482
|
+
deviceId: 'device-id-1',
|
|
1483
|
+
deviceCallId: 'call-id-1',
|
|
1484
|
+
};
|
|
1485
|
+
const taskData = createBaseData({
|
|
1486
|
+
agentId: 'agent-1',
|
|
1487
|
+
interaction: {
|
|
1488
|
+
participants: {
|
|
1489
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1490
|
+
},
|
|
1491
|
+
} as any,
|
|
1492
|
+
});
|
|
1493
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1494
|
+
enableWxBetterTogether: true,
|
|
1495
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1496
|
+
});
|
|
1497
|
+
primeConnectedState(voice, taskData);
|
|
1498
|
+
|
|
1499
|
+
await voice.syncWxAppMuteFromCallDetails();
|
|
1500
|
+
|
|
1501
|
+
expect(mockSvc.getCallDetails).toHaveBeenCalled();
|
|
1502
|
+
expect(errorSpy).not.toHaveBeenCalled();
|
|
1503
|
+
errorSpy.mockRestore();
|
|
1504
|
+
});
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
describe('decline', () => {
|
|
1508
|
+
const wxAppParticipant = {
|
|
1509
|
+
deviceType: 'wxApp',
|
|
1510
|
+
deviceId: 'device-id-1',
|
|
1511
|
+
deviceCallId: 'call-id-1',
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
const makeWxAppOutdialTaskData = () =>
|
|
1515
|
+
createBaseData({
|
|
1516
|
+
agentId: 'agent-1',
|
|
1517
|
+
interaction: {
|
|
1518
|
+
outboundType: 'OUTDIAL',
|
|
1519
|
+
participants: {
|
|
1520
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1521
|
+
},
|
|
1522
|
+
} as any,
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
it('cancels wxApp outdial via contact.cancelTask', async () => {
|
|
1526
|
+
const taskData = makeWxAppOutdialTaskData();
|
|
1527
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
|
|
1528
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1529
|
+
|
|
1530
|
+
await voice.decline();
|
|
1531
|
+
|
|
1532
|
+
expect(dummyContact.cancelTask).toHaveBeenCalledWith({interactionId: 'int1'});
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
it('emits TASK_DECLINE and WXAPP_TASK_DECLINE success metrics for wxApp outdial decline', async () => {
|
|
1536
|
+
const taskData = makeWxAppOutdialTaskData();
|
|
1537
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
|
|
1538
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1539
|
+
const metricsManager = MetricsManager.getInstance();
|
|
1540
|
+
const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
|
|
1541
|
+
|
|
1542
|
+
await voice.decline();
|
|
1543
|
+
|
|
1544
|
+
expect(trackEventSpy).toHaveBeenCalledWith(
|
|
1545
|
+
METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
|
|
1546
|
+
expect.objectContaining({taskId: 'int1'}),
|
|
1547
|
+
['operational', 'behavioral']
|
|
1548
|
+
);
|
|
1549
|
+
expect(trackEventSpy).toHaveBeenCalledWith(
|
|
1550
|
+
METRIC_EVENT_NAMES.TASK_DECLINE_SUCCESS,
|
|
1551
|
+
expect.objectContaining({taskId: 'int1'}),
|
|
1552
|
+
['operational', 'behavioral']
|
|
1553
|
+
);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
it('emits TASK_DECLINE and WXAPP_TASK_DECLINE failed metrics when outdial cancel fails', async () => {
|
|
1557
|
+
const cancelError = Object.assign(new Error('AQM failed'), {
|
|
1558
|
+
details: {
|
|
1559
|
+
trackingId: 'aqm-track-1',
|
|
1560
|
+
orgId: 'org-1',
|
|
1561
|
+
type: 'Service.aqm.task.cancel',
|
|
1562
|
+
data: {reason: 'TASK_NOT_FOUND', reasonCode: 404, agentId: 'agent-1'},
|
|
1563
|
+
},
|
|
1564
|
+
});
|
|
1565
|
+
dummyContact.cancelTask = jest.fn().mockRejectedValue(cancelError);
|
|
1566
|
+
const taskData = makeWxAppOutdialTaskData();
|
|
1567
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
|
|
1568
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1569
|
+
const metricsManager = MetricsManager.getInstance();
|
|
1570
|
+
const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
|
|
1571
|
+
|
|
1572
|
+
await expect(voice.decline()).rejects.toThrow('AQM failed');
|
|
1573
|
+
|
|
1574
|
+
expect(trackEventSpy).toHaveBeenCalledWith(
|
|
1575
|
+
METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
|
|
1576
|
+
expect.objectContaining({
|
|
1577
|
+
taskId: 'int1',
|
|
1578
|
+
trackingId: 'aqm-track-1',
|
|
1579
|
+
failureReason: 'TASK_NOT_FOUND',
|
|
1580
|
+
}),
|
|
1581
|
+
['operational', 'behavioral']
|
|
1582
|
+
);
|
|
1583
|
+
expect(trackEventSpy).toHaveBeenCalledWith(
|
|
1584
|
+
METRIC_EVENT_NAMES.TASK_DECLINE_FAILED,
|
|
1585
|
+
expect.objectContaining({
|
|
1586
|
+
taskId: 'int1',
|
|
1587
|
+
trackingId: 'aqm-track-1',
|
|
1588
|
+
failureReason: 'TASK_NOT_FOUND',
|
|
1589
|
+
}),
|
|
1590
|
+
['operational', 'behavioral']
|
|
1591
|
+
);
|
|
1592
|
+
dummyContact.cancelTask = jest.fn().mockResolvedValue(undefined);
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
it('clears wxAppAnswerPending when declining outdial', async () => {
|
|
1596
|
+
const mockSvc = {
|
|
1597
|
+
answerCall: jest.fn().mockResolvedValue(undefined),
|
|
1598
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: false}),
|
|
1599
|
+
};
|
|
1600
|
+
const taskData = makeWxAppOutdialTaskData();
|
|
1601
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1602
|
+
enableWxBetterTogether: true,
|
|
1603
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1604
|
+
});
|
|
1605
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1606
|
+
|
|
1607
|
+
await voice.accept();
|
|
1608
|
+
expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
|
|
1609
|
+
expect(voice.uiControlConfig.wxAppAcceptInFlight).toBe(false);
|
|
1610
|
+
|
|
1611
|
+
await voice.decline();
|
|
1612
|
+
|
|
1613
|
+
expect(voice.uiControlConfig.wxAppAnswerPending).toBe(false);
|
|
1614
|
+
expect(dummyContact.cancelTask).toHaveBeenCalledWith({interactionId: 'int1'});
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
it('throws unsupported for non-outdial voice tasks', async () => {
|
|
1618
|
+
const taskData = createBaseData();
|
|
1619
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
|
|
1620
|
+
|
|
1621
|
+
await expect(voice.decline()).rejects.toThrow('Unsupported operation: decline');
|
|
1622
|
+
expect(dummyContact.cancelTask).not.toHaveBeenCalled();
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
it('throws unsupported when enableWxBetterTogether is false', async () => {
|
|
1626
|
+
const taskData = makeWxAppOutdialTaskData();
|
|
1627
|
+
const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: false});
|
|
1628
|
+
|
|
1629
|
+
await expect(voice.decline()).rejects.toThrow('Unsupported operation: decline');
|
|
1630
|
+
expect(dummyContact.cancelTask).not.toHaveBeenCalled();
|
|
1631
|
+
});
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
describe('unified task API routing (WXCC-6026)', () => {
|
|
1635
|
+
const wxAppParticipant = {
|
|
1636
|
+
deviceType: 'wxApp',
|
|
1637
|
+
deviceId: 'device-id-1',
|
|
1638
|
+
deviceCallId: 'call-id-1',
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
it('accept() routes wxApp inbound offer to telephony answerCall', async () => {
|
|
1642
|
+
const mockSvc = {
|
|
1643
|
+
answerCall: jest.fn().mockResolvedValue(undefined),
|
|
1644
|
+
getCallDetails: jest.fn().mockResolvedValue({muted: false}),
|
|
1645
|
+
};
|
|
1646
|
+
const taskData = createBaseData({
|
|
1647
|
+
agentId: 'agent-1',
|
|
1648
|
+
interaction: {
|
|
1649
|
+
participants: {
|
|
1650
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1651
|
+
},
|
|
1652
|
+
} as any,
|
|
1653
|
+
});
|
|
1654
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1655
|
+
enableWxBetterTogether: true,
|
|
1656
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1657
|
+
});
|
|
1658
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1659
|
+
|
|
1660
|
+
await voice.accept();
|
|
1661
|
+
|
|
1662
|
+
expect(mockSvc.answerCall).toHaveBeenCalledWith({
|
|
1663
|
+
callId: 'call-id-1',
|
|
1664
|
+
endpointId: 'device-id-1',
|
|
1665
|
+
lineOwnerId: undefined,
|
|
1666
|
+
});
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
it('decline() routes wxApp inbound offer to telephony rejectCall', async () => {
|
|
1670
|
+
const mockSvc = {
|
|
1671
|
+
rejectCall: jest.fn().mockResolvedValue(undefined),
|
|
1672
|
+
};
|
|
1673
|
+
const taskData = createBaseData({
|
|
1674
|
+
agentId: 'agent-1',
|
|
1675
|
+
interaction: {
|
|
1676
|
+
participants: {
|
|
1677
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1678
|
+
},
|
|
1679
|
+
} as any,
|
|
1680
|
+
});
|
|
1681
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1682
|
+
enableWxBetterTogether: true,
|
|
1683
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1684
|
+
});
|
|
1685
|
+
voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
|
|
1686
|
+
|
|
1687
|
+
await voice.decline();
|
|
1688
|
+
|
|
1689
|
+
expect(mockSvc.rejectCall).toHaveBeenCalledWith({
|
|
1690
|
+
callId: 'call-id-1',
|
|
1691
|
+
lineOwnerId: undefined,
|
|
1692
|
+
});
|
|
1693
|
+
expect(dummyContact.cancelTask).not.toHaveBeenCalled();
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
it('toggleMute() routes wxApp engaged call to telephony muteCall', async () => {
|
|
1697
|
+
const mockSvc = {
|
|
1698
|
+
muteCall: jest.fn().mockResolvedValue(undefined),
|
|
1699
|
+
};
|
|
1700
|
+
const taskData = createBaseData({
|
|
1701
|
+
agentId: 'agent-1',
|
|
1702
|
+
interaction: {
|
|
1703
|
+
participants: {
|
|
1704
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1705
|
+
},
|
|
1706
|
+
} as any,
|
|
1707
|
+
});
|
|
1708
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1709
|
+
enableWxBetterTogether: true,
|
|
1710
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1711
|
+
});
|
|
1712
|
+
primeConnectedState(voice, taskData);
|
|
1713
|
+
|
|
1714
|
+
await voice.toggleMute({muted: true});
|
|
1715
|
+
|
|
1716
|
+
expect(mockSvc.muteCall).toHaveBeenCalledWith({callId: 'call-id-1', lineOwnerId: undefined});
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
it('serializes concurrent no-arg toggleMute() so mute then unmute', async () => {
|
|
1720
|
+
let resolveMute: () => void;
|
|
1721
|
+
const muteGate = new Promise<void>((resolve) => {
|
|
1722
|
+
resolveMute = resolve;
|
|
1723
|
+
});
|
|
1724
|
+
const mockSvc = {
|
|
1725
|
+
muteCall: jest.fn().mockImplementation(() => muteGate),
|
|
1726
|
+
unmuteCall: jest.fn().mockResolvedValue(undefined),
|
|
1727
|
+
};
|
|
1728
|
+
const taskData = createBaseData({
|
|
1729
|
+
agentId: 'agent-1',
|
|
1730
|
+
interaction: {
|
|
1731
|
+
participants: {
|
|
1732
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1733
|
+
},
|
|
1734
|
+
} as any,
|
|
1735
|
+
});
|
|
1736
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1737
|
+
enableWxBetterTogether: true,
|
|
1738
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1739
|
+
});
|
|
1740
|
+
primeConnectedState(voice, taskData);
|
|
1741
|
+
expect(voice.getWxAppMuted()).toBe(false);
|
|
1742
|
+
|
|
1743
|
+
const firstToggle = voice.toggleMute();
|
|
1744
|
+
const secondToggle = voice.toggleMute();
|
|
1745
|
+
|
|
1746
|
+
await new Promise<void>((resolve) => {
|
|
1747
|
+
setImmediate(resolve);
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
|
|
1751
|
+
expect(mockSvc.unmuteCall).not.toHaveBeenCalled();
|
|
1752
|
+
|
|
1753
|
+
resolveMute!();
|
|
1754
|
+
await Promise.all([firstToggle, secondToggle]);
|
|
1755
|
+
|
|
1756
|
+
expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
|
|
1757
|
+
expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
|
|
1758
|
+
expect(voice.getWxAppMuted()).toBe(false);
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
it('queues three concurrent no-arg toggleMute() calls in order', async () => {
|
|
1762
|
+
let resolveFirstMute: () => void;
|
|
1763
|
+
const firstMuteGate = new Promise<void>((resolve) => {
|
|
1764
|
+
resolveFirstMute = resolve;
|
|
1765
|
+
});
|
|
1766
|
+
const mockSvc = {
|
|
1767
|
+
muteCall: jest
|
|
1768
|
+
.fn()
|
|
1769
|
+
.mockImplementationOnce(() => firstMuteGate)
|
|
1770
|
+
.mockResolvedValue(undefined),
|
|
1771
|
+
unmuteCall: jest.fn().mockResolvedValue(undefined),
|
|
1772
|
+
};
|
|
1773
|
+
const taskData = createBaseData({
|
|
1774
|
+
agentId: 'agent-1',
|
|
1775
|
+
interaction: {
|
|
1776
|
+
participants: {
|
|
1777
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1778
|
+
},
|
|
1779
|
+
} as any,
|
|
1780
|
+
});
|
|
1781
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1782
|
+
enableWxBetterTogether: true,
|
|
1783
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1784
|
+
});
|
|
1785
|
+
primeConnectedState(voice, taskData);
|
|
1786
|
+
expect(voice.getWxAppMuted()).toBe(false);
|
|
1787
|
+
|
|
1788
|
+
const firstToggle = voice.toggleMute();
|
|
1789
|
+
const secondToggle = voice.toggleMute();
|
|
1790
|
+
const thirdToggle = voice.toggleMute();
|
|
1791
|
+
|
|
1792
|
+
await new Promise<void>((resolve) => {
|
|
1793
|
+
setImmediate(resolve);
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
|
|
1797
|
+
expect(mockSvc.unmuteCall).not.toHaveBeenCalled();
|
|
1798
|
+
|
|
1799
|
+
resolveFirstMute!();
|
|
1800
|
+
await Promise.all([firstToggle, secondToggle, thirdToggle]);
|
|
1801
|
+
|
|
1802
|
+
expect(mockSvc.muteCall).toHaveBeenCalledTimes(2);
|
|
1803
|
+
expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
|
|
1804
|
+
expect(voice.getWxAppMuted()).toBe(true);
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
it('records duration_ms on each serialized toggleMute success metric', async () => {
|
|
1808
|
+
let resolveFirstMute: () => void;
|
|
1809
|
+
const firstMuteGate = new Promise<void>((resolve) => {
|
|
1810
|
+
resolveFirstMute = resolve;
|
|
1811
|
+
});
|
|
1812
|
+
const mockSvc = {
|
|
1813
|
+
muteCall: jest.fn().mockImplementationOnce(() => firstMuteGate),
|
|
1814
|
+
unmuteCall: jest.fn().mockResolvedValue(undefined),
|
|
1815
|
+
};
|
|
1816
|
+
const taskData = createBaseData({
|
|
1817
|
+
agentId: 'agent-1',
|
|
1818
|
+
interaction: {
|
|
1819
|
+
participants: {
|
|
1820
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1821
|
+
},
|
|
1822
|
+
} as any,
|
|
1823
|
+
});
|
|
1824
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1825
|
+
enableWxBetterTogether: true,
|
|
1826
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1827
|
+
});
|
|
1828
|
+
primeConnectedState(voice, taskData);
|
|
1829
|
+
const metricsManager = MetricsManager.getInstance();
|
|
1830
|
+
const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
|
|
1831
|
+
|
|
1832
|
+
const firstToggle = voice.toggleMute();
|
|
1833
|
+
const secondToggle = voice.toggleMute();
|
|
1834
|
+
|
|
1835
|
+
resolveFirstMute!();
|
|
1836
|
+
await Promise.all([firstToggle, secondToggle]);
|
|
1837
|
+
|
|
1838
|
+
const successCalls = trackEventSpy.mock.calls.filter(
|
|
1839
|
+
([eventName]) => eventName === METRIC_EVENT_NAMES.WXAPP_TASK_MUTE_SUCCESS
|
|
1840
|
+
);
|
|
1841
|
+
expect(successCalls).toHaveLength(2);
|
|
1842
|
+
successCalls.forEach(([, payload]) => {
|
|
1843
|
+
expect(payload).toEqual(
|
|
1844
|
+
expect.objectContaining({
|
|
1845
|
+
duration_ms: expect.any(Number),
|
|
1846
|
+
})
|
|
1847
|
+
);
|
|
1848
|
+
});
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
it('continues queued toggleMute after predecessor failure', async () => {
|
|
1852
|
+
const mockSvc = {
|
|
1853
|
+
muteCall: jest.fn().mockRejectedValue(new Error('mute failed')),
|
|
1854
|
+
unmuteCall: jest.fn().mockResolvedValue(undefined),
|
|
1855
|
+
};
|
|
1856
|
+
const taskData = createBaseData({
|
|
1857
|
+
agentId: 'agent-1',
|
|
1858
|
+
interaction: {
|
|
1859
|
+
participants: {
|
|
1860
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1861
|
+
},
|
|
1862
|
+
} as any,
|
|
1863
|
+
});
|
|
1864
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1865
|
+
enableWxBetterTogether: true,
|
|
1866
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1867
|
+
});
|
|
1868
|
+
primeConnectedState(voice, taskData);
|
|
1869
|
+
|
|
1870
|
+
const firstToggle = voice.toggleMute({muted: true});
|
|
1871
|
+
const secondToggle = voice.toggleMute({muted: false});
|
|
1872
|
+
|
|
1873
|
+
await expect(firstToggle).rejects.toThrow('mute failed');
|
|
1874
|
+
await secondToggle;
|
|
1875
|
+
|
|
1876
|
+
expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
|
|
1877
|
+
expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
it('transmitDtmf() routes wxApp engaged call to telephony transmitDtmf', async () => {
|
|
1881
|
+
const mockSvc = {
|
|
1882
|
+
transmitDtmf: jest.fn().mockResolvedValue(undefined),
|
|
1883
|
+
};
|
|
1884
|
+
const taskData = createBaseData({
|
|
1885
|
+
agentId: 'agent-1',
|
|
1886
|
+
interaction: {
|
|
1887
|
+
participants: {
|
|
1888
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1889
|
+
},
|
|
1890
|
+
} as any,
|
|
1891
|
+
});
|
|
1892
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1893
|
+
enableWxBetterTogether: true,
|
|
1894
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1895
|
+
});
|
|
1896
|
+
primeConnectedState(voice, taskData);
|
|
1897
|
+
|
|
1898
|
+
await voice.transmitDtmf({dtmf: '5'});
|
|
1899
|
+
|
|
1900
|
+
expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
|
|
1901
|
+
callId: 'call-id-1',
|
|
1902
|
+
dtmf: '5',
|
|
1903
|
+
lineOwnerId: undefined,
|
|
1904
|
+
});
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
it('serializes concurrent transmitDtmf() so each digit completes before the next', async () => {
|
|
1908
|
+
let resolveFirst: () => void;
|
|
1909
|
+
const firstGate = new Promise<void>((resolve) => {
|
|
1910
|
+
resolveFirst = resolve;
|
|
1911
|
+
});
|
|
1912
|
+
const mockSvc = {
|
|
1913
|
+
transmitDtmf: jest
|
|
1914
|
+
.fn()
|
|
1915
|
+
.mockImplementationOnce(() => firstGate)
|
|
1916
|
+
.mockResolvedValueOnce(undefined),
|
|
1917
|
+
};
|
|
1918
|
+
const taskData = createBaseData({
|
|
1919
|
+
agentId: 'agent-1',
|
|
1920
|
+
interaction: {
|
|
1921
|
+
participants: {
|
|
1922
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1923
|
+
},
|
|
1924
|
+
} as any,
|
|
1925
|
+
});
|
|
1926
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1927
|
+
enableWxBetterTogether: true,
|
|
1928
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1929
|
+
});
|
|
1930
|
+
primeConnectedState(voice, taskData);
|
|
1931
|
+
|
|
1932
|
+
const firstDtmf = voice.transmitDtmf({dtmf: '1'});
|
|
1933
|
+
const secondDtmf = voice.transmitDtmf({dtmf: '2'});
|
|
1934
|
+
|
|
1935
|
+
await new Promise<void>((resolve) => {
|
|
1936
|
+
setImmediate(resolve);
|
|
1937
|
+
});
|
|
1938
|
+
|
|
1939
|
+
expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(1);
|
|
1940
|
+
expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
|
|
1941
|
+
callId: 'call-id-1',
|
|
1942
|
+
dtmf: '1',
|
|
1943
|
+
lineOwnerId: undefined,
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
resolveFirst!();
|
|
1947
|
+
await Promise.all([firstDtmf, secondDtmf]);
|
|
1948
|
+
|
|
1949
|
+
expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(2);
|
|
1950
|
+
expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(2, {
|
|
1951
|
+
callId: 'call-id-1',
|
|
1952
|
+
dtmf: '2',
|
|
1953
|
+
lineOwnerId: undefined,
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
it('queues three concurrent transmitDtmf() calls in order', async () => {
|
|
1958
|
+
let resolveFirst: () => void;
|
|
1959
|
+
const firstGate = new Promise<void>((resolve) => {
|
|
1960
|
+
resolveFirst = resolve;
|
|
1961
|
+
});
|
|
1962
|
+
const mockSvc = {
|
|
1963
|
+
transmitDtmf: jest
|
|
1964
|
+
.fn()
|
|
1965
|
+
.mockImplementationOnce(() => firstGate)
|
|
1966
|
+
.mockResolvedValue(undefined),
|
|
1967
|
+
};
|
|
1968
|
+
const taskData = createBaseData({
|
|
1969
|
+
agentId: 'agent-1',
|
|
1970
|
+
interaction: {
|
|
1971
|
+
participants: {
|
|
1972
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
1973
|
+
},
|
|
1974
|
+
} as any,
|
|
1975
|
+
});
|
|
1976
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
1977
|
+
enableWxBetterTogether: true,
|
|
1978
|
+
answerCallOnWebexService: mockSvc as any,
|
|
1979
|
+
});
|
|
1980
|
+
primeConnectedState(voice, taskData);
|
|
1981
|
+
|
|
1982
|
+
const firstDtmf = voice.transmitDtmf({dtmf: '1'});
|
|
1983
|
+
const secondDtmf = voice.transmitDtmf({dtmf: '2'});
|
|
1984
|
+
const thirdDtmf = voice.transmitDtmf({dtmf: '3'});
|
|
1985
|
+
|
|
1986
|
+
await new Promise<void>((resolve) => {
|
|
1987
|
+
setImmediate(resolve);
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(1);
|
|
1991
|
+
expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
|
|
1992
|
+
callId: 'call-id-1',
|
|
1993
|
+
dtmf: '1',
|
|
1994
|
+
lineOwnerId: undefined,
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
resolveFirst!();
|
|
1998
|
+
await Promise.all([firstDtmf, secondDtmf, thirdDtmf]);
|
|
1999
|
+
|
|
2000
|
+
expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(3);
|
|
2001
|
+
expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(2, {
|
|
2002
|
+
callId: 'call-id-1',
|
|
2003
|
+
dtmf: '2',
|
|
2004
|
+
lineOwnerId: undefined,
|
|
2005
|
+
});
|
|
2006
|
+
expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(3, {
|
|
2007
|
+
callId: 'call-id-1',
|
|
2008
|
+
dtmf: '3',
|
|
2009
|
+
lineOwnerId: undefined,
|
|
2010
|
+
});
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
it('records duration_ms on each serialized transmitDtmf success metric', async () => {
|
|
2014
|
+
let resolveFirst: () => void;
|
|
2015
|
+
const firstGate = new Promise<void>((resolve) => {
|
|
2016
|
+
resolveFirst = resolve;
|
|
2017
|
+
});
|
|
2018
|
+
const mockSvc = {
|
|
2019
|
+
transmitDtmf: jest
|
|
2020
|
+
.fn()
|
|
2021
|
+
.mockImplementationOnce(() => firstGate)
|
|
2022
|
+
.mockResolvedValueOnce(undefined),
|
|
2023
|
+
};
|
|
2024
|
+
const taskData = createBaseData({
|
|
2025
|
+
agentId: 'agent-1',
|
|
2026
|
+
interaction: {
|
|
2027
|
+
participants: {
|
|
2028
|
+
'agent-1': {id: 'agent-1', ...wxAppParticipant},
|
|
2029
|
+
},
|
|
2030
|
+
} as any,
|
|
2031
|
+
});
|
|
2032
|
+
const voice = new Voice(dummyContact, taskData, {
|
|
2033
|
+
enableWxBetterTogether: true,
|
|
2034
|
+
answerCallOnWebexService: mockSvc as any,
|
|
2035
|
+
});
|
|
2036
|
+
primeConnectedState(voice, taskData);
|
|
2037
|
+
const metricsManager = MetricsManager.getInstance();
|
|
2038
|
+
const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
|
|
2039
|
+
|
|
2040
|
+
const firstDtmf = voice.transmitDtmf({dtmf: '1'});
|
|
2041
|
+
const secondDtmf = voice.transmitDtmf({dtmf: '2'});
|
|
2042
|
+
|
|
2043
|
+
resolveFirst!();
|
|
2044
|
+
await Promise.all([firstDtmf, secondDtmf]);
|
|
2045
|
+
|
|
2046
|
+
const successCalls = trackEventSpy.mock.calls.filter(
|
|
2047
|
+
([eventName]) => eventName === METRIC_EVENT_NAMES.WXAPP_TASK_DTMF_SUCCESS
|
|
2048
|
+
);
|
|
2049
|
+
expect(successCalls).toHaveLength(2);
|
|
2050
|
+
successCalls.forEach(([, payload]) => {
|
|
2051
|
+
expect(payload).toEqual(
|
|
2052
|
+
expect.objectContaining({
|
|
2053
|
+
duration_ms: expect.any(Number),
|
|
2054
|
+
})
|
|
2055
|
+
);
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
trackEventSpy.mockRestore();
|
|
2059
|
+
});
|
|
2060
|
+
});
|
|
2061
|
+
});
|