@webex/contact-center 3.11.0 → 3.12.0-auth-prejoin-fetch.1

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.
Files changed (325) hide show
  1. package/.sdd/manifest.json +883 -0
  2. package/AGENTS.md +94 -0
  3. package/ai-docs/ARCHITECTURE.md +168 -0
  4. package/ai-docs/CONTRACTS.md +50 -0
  5. package/ai-docs/GETTING_STARTED.md +168 -0
  6. package/ai-docs/GLOSSARY.md +43 -0
  7. package/ai-docs/README.md +138 -0
  8. package/ai-docs/REVIEW_CHECKLIST.md +41 -0
  9. package/ai-docs/RULES.md +444 -0
  10. package/ai-docs/SECURITY.md +52 -0
  11. package/ai-docs/SERVICE_STATE.md +48 -0
  12. package/ai-docs/SPEC_INDEX.md +65 -0
  13. package/ai-docs/adr/0001-spec-source-policy.md +55 -0
  14. package/ai-docs/adr/README.md +8 -0
  15. package/ai-docs/adr/_adr-template.md +31 -0
  16. package/ai-docs/contact-center-spec.md +359 -0
  17. package/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +362 -0
  18. package/ai-docs/features/generated-spec-conformance-fidelity-remediation/spec/feature-spec.md +117 -0
  19. package/ai-docs/features/residual-warning-coverage-completion/spec/feature-spec.md +203 -0
  20. package/ai-docs/features/validator-code-fidelity-drift-fix/spec/feature-spec.md +315 -0
  21. package/ai-docs/patterns/event-driven-patterns.md +485 -0
  22. package/ai-docs/patterns/testing-patterns.md +480 -0
  23. package/ai-docs/patterns/typescript-patterns.md +365 -0
  24. package/ai-docs/templates/README.md +102 -0
  25. package/ai-docs/templates/documentation/create-agents-md.md +240 -0
  26. package/ai-docs/templates/documentation/create-architecture-md.md +295 -0
  27. package/ai-docs/templates/existing-service/bug-fix.md +254 -0
  28. package/ai-docs/templates/existing-service/feature-enhancement.md +450 -0
  29. package/ai-docs/templates/new-method/00-master.md +80 -0
  30. package/ai-docs/templates/new-method/01-requirements.md +232 -0
  31. package/ai-docs/templates/new-method/02-implementation.md +295 -0
  32. package/ai-docs/templates/new-method/03-tests.md +201 -0
  33. package/ai-docs/templates/new-method/04-validation.md +141 -0
  34. package/ai-docs/templates/new-service/00-master.md +109 -0
  35. package/ai-docs/templates/new-service/01-pre-questions.md +159 -0
  36. package/ai-docs/templates/new-service/02-code-generation.md +346 -0
  37. package/ai-docs/templates/new-service/03-integration.md +178 -0
  38. package/ai-docs/templates/new-service/04-test-generation.md +205 -0
  39. package/ai-docs/templates/new-service/05-validation.md +145 -0
  40. package/dist/cc.js +818 -59
  41. package/dist/cc.js.map +1 -1
  42. package/dist/config.js +13 -0
  43. package/dist/config.js.map +1 -1
  44. package/dist/constants.js +31 -3
  45. package/dist/constants.js.map +1 -1
  46. package/dist/index.js +27 -5
  47. package/dist/index.js.map +1 -1
  48. package/dist/metrics/behavioral-events.js +127 -0
  49. package/dist/metrics/behavioral-events.js.map +1 -1
  50. package/dist/metrics/constants.js +34 -3
  51. package/dist/metrics/constants.js.map +1 -1
  52. package/dist/services/AddressBook.js +18 -15
  53. package/dist/services/AddressBook.js.map +1 -1
  54. package/dist/services/AnswerCallOnWebexService.js +174 -0
  55. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  56. package/dist/services/ApiAiAssistant.js +318 -0
  57. package/dist/services/ApiAiAssistant.js.map +1 -0
  58. package/dist/services/EntryPoint.js +46 -68
  59. package/dist/services/EntryPoint.js.map +1 -1
  60. package/dist/services/Queue.js +27 -22
  61. package/dist/services/Queue.js.map +1 -1
  62. package/dist/services/UserPreference.js +427 -0
  63. package/dist/services/UserPreference.js.map +1 -0
  64. package/dist/services/WebexCrossClientService.js +171 -0
  65. package/dist/services/WebexCrossClientService.js.map +1 -0
  66. package/dist/services/WxAppTelephonyMercurySync.js +93 -0
  67. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  68. package/dist/services/agent/types.js.map +1 -1
  69. package/dist/services/config/Util.js +11 -4
  70. package/dist/services/config/Util.js.map +1 -1
  71. package/dist/services/config/constants.js +45 -8
  72. package/dist/services/config/constants.js.map +1 -1
  73. package/dist/services/config/index.js +41 -2
  74. package/dist/services/config/index.js.map +1 -1
  75. package/dist/services/config/types.js +70 -8
  76. package/dist/services/config/types.js.map +1 -1
  77. package/dist/services/constants.js +27 -1
  78. package/dist/services/constants.js.map +1 -1
  79. package/dist/services/core/Err.js.map +1 -1
  80. package/dist/services/core/Utils.js +122 -25
  81. package/dist/services/core/Utils.js.map +1 -1
  82. package/dist/services/core/WebexRequest.js +6 -2
  83. package/dist/services/core/WebexRequest.js.map +1 -1
  84. package/dist/services/core/aqm-reqs.js +119 -30
  85. package/dist/services/core/aqm-reqs.js.map +1 -1
  86. package/dist/services/core/types.js.map +1 -1
  87. package/dist/services/core/websocket/WebSocketManager.js +22 -6
  88. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  89. package/dist/services/core/websocket/connection-service.js +3 -1
  90. package/dist/services/core/websocket/connection-service.js.map +1 -1
  91. package/dist/services/core/websocket/types.js.map +1 -1
  92. package/dist/services/index.js +6 -0
  93. package/dist/services/index.js.map +1 -1
  94. package/dist/services/task/Task.js +754 -0
  95. package/dist/services/task/Task.js.map +1 -0
  96. package/dist/services/task/TaskFactory.js +49 -0
  97. package/dist/services/task/TaskFactory.js.map +1 -0
  98. package/dist/services/task/TaskManager.js +1073 -447
  99. package/dist/services/task/TaskManager.js.map +1 -1
  100. package/dist/services/task/TaskUtils.js +220 -23
  101. package/dist/services/task/TaskUtils.js.map +1 -1
  102. package/dist/services/task/WebexCallingUtils.js +70 -0
  103. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  104. package/dist/services/task/constants.js +26 -2
  105. package/dist/services/task/constants.js.map +1 -1
  106. package/dist/services/task/contact.js +29 -0
  107. package/dist/services/task/contact.js.map +1 -1
  108. package/dist/services/task/dialer.js +129 -0
  109. package/dist/services/task/dialer.js.map +1 -1
  110. package/dist/services/task/digital/Digital.js +78 -0
  111. package/dist/services/task/digital/Digital.js.map +1 -0
  112. package/dist/services/task/state-machine/TaskStateMachine.js +971 -0
  113. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
  114. package/dist/services/task/state-machine/actions.js +572 -0
  115. package/dist/services/task/state-machine/actions.js.map +1 -0
  116. package/dist/services/task/state-machine/constants.js +161 -0
  117. package/dist/services/task/state-machine/constants.js.map +1 -0
  118. package/dist/services/task/state-machine/guards.js +409 -0
  119. package/dist/services/task/state-machine/guards.js.map +1 -0
  120. package/dist/services/task/state-machine/index.js +53 -0
  121. package/dist/services/task/state-machine/index.js.map +1 -0
  122. package/dist/services/task/state-machine/types.js +54 -0
  123. package/dist/services/task/state-machine/types.js.map +1 -0
  124. package/dist/services/task/state-machine/uiControlsComputer.js +703 -0
  125. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
  126. package/dist/services/task/taskDataNormalizer.js +99 -0
  127. package/dist/services/task/taskDataNormalizer.js.map +1 -0
  128. package/dist/services/task/types.js +243 -5
  129. package/dist/services/task/types.js.map +1 -1
  130. package/dist/services/task/voice/Voice.js +1380 -0
  131. package/dist/services/task/voice/Voice.js.map +1 -0
  132. package/dist/services/task/voice/WebRTC.js +152 -0
  133. package/dist/services/task/voice/WebRTC.js.map +1 -0
  134. package/dist/services/task/voice/wxAppVoiceMethods.js +201 -0
  135. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  136. package/dist/services/wxAppTelephonyUtils.js +19 -0
  137. package/dist/services/wxAppTelephonyUtils.js.map +1 -0
  138. package/dist/types/cc.d.ts +967 -0
  139. package/dist/types/config.d.ts +79 -0
  140. package/dist/types/constants.d.ts +74 -0
  141. package/dist/types/index.d.ts +201 -0
  142. package/dist/types/logger-proxy.d.ts +71 -0
  143. package/dist/types/metrics/MetricsManager.d.ts +223 -0
  144. package/dist/types/metrics/behavioral-events.d.ts +29 -0
  145. package/dist/types/metrics/constants.d.ts +183 -0
  146. package/dist/types/services/AddressBook.d.ts +75 -0
  147. package/dist/types/services/AnswerCallOnWebexService.d.ts +37 -0
  148. package/dist/types/services/ApiAiAssistant.d.ts +49 -0
  149. package/dist/types/services/EntryPoint.d.ts +69 -0
  150. package/dist/types/services/Queue.d.ts +78 -0
  151. package/dist/types/services/UserPreference.d.ts +118 -0
  152. package/dist/types/services/WebCallingService.d.ts +1 -0
  153. package/dist/types/services/WebexCrossClientService.d.ts +28 -0
  154. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  155. package/dist/types/services/agent/index.d.ts +46 -0
  156. package/dist/types/services/agent/types.d.ts +413 -0
  157. package/dist/types/services/config/Util.d.ts +20 -0
  158. package/dist/types/services/config/constants.d.ts +273 -0
  159. package/dist/types/services/config/index.d.ts +177 -0
  160. package/dist/types/services/config/types.d.ts +1381 -0
  161. package/dist/types/services/constants.d.ts +110 -0
  162. package/dist/types/services/core/Err.d.ts +127 -0
  163. package/dist/types/services/core/GlobalTypes.d.ts +58 -0
  164. package/dist/types/services/core/Utils.d.ts +121 -0
  165. package/dist/types/services/core/WebexRequest.d.ts +23 -0
  166. package/dist/types/services/core/aqm-reqs.d.ts +65 -0
  167. package/dist/types/services/core/constants.d.ts +99 -0
  168. package/dist/types/services/core/types.d.ts +49 -0
  169. package/dist/types/services/core/websocket/WebSocketManager.d.ts +36 -0
  170. package/dist/types/services/core/websocket/connection-service.d.ts +27 -0
  171. package/dist/types/services/core/websocket/keepalive.worker.d.ts +2 -0
  172. package/dist/types/services/core/websocket/types.d.ts +37 -0
  173. package/dist/types/services/index.d.ts +54 -0
  174. package/dist/types/services/task/AutoWrapup.d.ts +40 -0
  175. package/dist/types/services/task/Task.d.ts +175 -0
  176. package/dist/types/services/task/TaskFactory.d.ts +13 -0
  177. package/dist/types/services/task/TaskManager.d.ts +1 -0
  178. package/dist/types/services/task/TaskUtils.d.ts +138 -0
  179. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  180. package/dist/types/services/task/constants.d.ts +94 -0
  181. package/dist/types/services/task/contact.d.ts +73 -0
  182. package/dist/types/services/task/dialer.d.ts +73 -0
  183. package/dist/types/services/task/digital/Digital.d.ts +22 -0
  184. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +1398 -0
  185. package/dist/types/services/task/state-machine/actions.d.ts +10 -0
  186. package/dist/types/services/task/state-machine/constants.d.ts +107 -0
  187. package/dist/types/services/task/state-machine/guards.d.ts +103 -0
  188. package/dist/types/services/task/state-machine/index.d.ts +13 -0
  189. package/dist/types/services/task/state-machine/types.d.ts +277 -0
  190. package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
  191. package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
  192. package/dist/types/services/task/types.d.ts +1933 -0
  193. package/dist/types/services/task/voice/Voice.d.ts +223 -0
  194. package/dist/types/services/task/voice/WebRTC.d.ts +53 -0
  195. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +52 -0
  196. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  197. package/dist/types/types.d.ts +784 -0
  198. package/dist/types/utils/PageCache.d.ts +190 -0
  199. package/dist/types/webex-config.d.ts +53 -0
  200. package/dist/types/webex.d.ts +8 -0
  201. package/dist/types.js +137 -3
  202. package/dist/types.js.map +1 -1
  203. package/dist/utils/PageCache.js +19 -5
  204. package/dist/utils/PageCache.js.map +1 -1
  205. package/dist/webex.js +14 -2
  206. package/dist/webex.js.map +1 -1
  207. package/package.json +16 -12
  208. package/src/cc.ts +983 -60
  209. package/src/config.ts +13 -0
  210. package/src/constants.ts +29 -1
  211. package/src/index.ts +26 -5
  212. package/src/metrics/ai-docs/AGENTS.md +350 -0
  213. package/src/metrics/ai-docs/ARCHITECTURE.md +338 -0
  214. package/src/metrics/ai-docs/metrics-spec.md +860 -0
  215. package/src/metrics/behavioral-events.ts +134 -0
  216. package/src/metrics/constants.ts +39 -3
  217. package/src/services/AddressBook.ts +17 -6
  218. package/src/services/AnswerCallOnWebexService.ts +206 -0
  219. package/src/services/ApiAiAssistant.ts +412 -0
  220. package/src/services/EntryPoint.ts +59 -60
  221. package/src/services/Queue.ts +29 -12
  222. package/src/services/UserPreference.ts +509 -0
  223. package/src/services/WebexCrossClientService.ts +212 -0
  224. package/src/services/WxAppTelephonyMercurySync.ts +115 -0
  225. package/src/services/agent/ai-docs/AGENTS.md +240 -0
  226. package/src/services/agent/ai-docs/ARCHITECTURE.md +304 -0
  227. package/src/services/agent/ai-docs/agent-spec.md +504 -0
  228. package/src/services/agent/types.ts +1 -1
  229. package/src/services/ai-docs/AGENTS.md +386 -0
  230. package/src/services/ai-docs/services-spec.md +497 -0
  231. package/src/services/config/Util.ts +13 -2
  232. package/src/services/config/ai-docs/AGENTS.md +255 -0
  233. package/src/services/config/ai-docs/ARCHITECTURE.md +426 -0
  234. package/src/services/config/ai-docs/config-spec.md +675 -0
  235. package/src/services/config/constants.ts +47 -7
  236. package/src/services/config/index.ts +45 -1
  237. package/src/services/config/types.ts +253 -11
  238. package/src/services/constants.ts +29 -0
  239. package/src/services/core/Err.ts +4 -0
  240. package/src/services/core/Utils.ts +143 -30
  241. package/src/services/core/WebexRequest.ts +3 -1
  242. package/src/services/core/ai-docs/AGENTS.md +381 -0
  243. package/src/services/core/ai-docs/ARCHITECTURE.md +698 -0
  244. package/src/services/core/ai-docs/core-spec.md +787 -0
  245. package/src/services/core/aqm-reqs.ts +125 -32
  246. package/src/services/core/types.ts +2 -0
  247. package/src/services/core/websocket/WebSocketManager.ts +23 -6
  248. package/src/services/core/websocket/connection-service.ts +5 -1
  249. package/src/services/core/websocket/types.ts +1 -1
  250. package/src/services/index.ts +4 -0
  251. package/src/services/task/Task.ts +908 -0
  252. package/src/services/task/TaskFactory.ts +60 -0
  253. package/src/services/task/TaskManager.ts +1291 -513
  254. package/src/services/task/TaskUtils.ts +314 -24
  255. package/src/services/task/WebexCallingUtils.ts +136 -0
  256. package/src/services/task/ai-docs/AGENTS.md +457 -0
  257. package/src/services/task/ai-docs/ARCHITECTURE.md +595 -0
  258. package/src/services/task/ai-docs/task-spec.md +1469 -0
  259. package/src/services/task/constants.ts +26 -0
  260. package/src/services/task/contact.ts +30 -0
  261. package/src/services/task/dialer.ts +136 -1
  262. package/src/services/task/digital/Digital.ts +97 -0
  263. package/src/services/task/state-machine/TaskStateMachine.ts +1313 -0
  264. package/src/services/task/state-machine/actions.ts +741 -0
  265. package/src/services/task/state-machine/ai-docs/AGENTS.md +462 -0
  266. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1146 -0
  267. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +2209 -0
  268. package/src/services/task/state-machine/constants.ts +172 -0
  269. package/src/services/task/state-machine/guards.ts +498 -0
  270. package/src/services/task/state-machine/index.ts +28 -0
  271. package/src/services/task/state-machine/types.ts +258 -0
  272. package/src/services/task/state-machine/uiControlsComputer.ts +1135 -0
  273. package/src/services/task/taskDataNormalizer.ts +137 -0
  274. package/src/services/task/types.ts +843 -70
  275. package/src/services/task/voice/Voice.ts +1720 -0
  276. package/src/services/task/voice/WebRTC.ts +191 -0
  277. package/src/services/task/voice/wxAppVoiceMethods.ts +307 -0
  278. package/src/services/wxAppTelephonyUtils.ts +14 -0
  279. package/src/types.ts +238 -11
  280. package/src/utils/AGENTS.md +289 -0
  281. package/src/utils/PageCache.ts +38 -5
  282. package/src/utils/ai-docs/utils-spec.md +391 -0
  283. package/src/webex.js +2 -0
  284. package/test/unit/spec/cc.ts +1856 -122
  285. package/test/unit/spec/logger-proxy.ts +70 -0
  286. package/test/unit/spec/metrics/behavioral-events.ts +18 -0
  287. package/test/unit/spec/services/AddressBook.ts +37 -6
  288. package/test/unit/spec/services/AnswerCallOnWebexService.ts +223 -0
  289. package/test/unit/spec/services/ApiAiAssistant.ts +273 -0
  290. package/test/unit/spec/services/EntryPoint.ts +87 -40
  291. package/test/unit/spec/services/Queue.ts +123 -12
  292. package/test/unit/spec/services/UserPreference.ts +401 -0
  293. package/test/unit/spec/services/WebCallingService.ts +7 -1
  294. package/test/unit/spec/services/WebexCrossClientService.ts +261 -0
  295. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +113 -0
  296. package/test/unit/spec/services/config/Util.ts +85 -0
  297. package/test/unit/spec/services/config/index.ts +85 -29
  298. package/test/unit/spec/services/core/Utils.ts +481 -2
  299. package/test/unit/spec/services/core/WebexRequest.ts +3 -1
  300. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  301. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +137 -41
  302. package/test/unit/spec/services/core/websocket/connection-service.ts +3 -1
  303. package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
  304. package/test/unit/spec/services/task/Task.ts +677 -0
  305. package/test/unit/spec/services/task/TaskFactory.ts +99 -0
  306. package/test/unit/spec/services/task/TaskManager.ts +2209 -918
  307. package/test/unit/spec/services/task/TaskUtils.ts +235 -0
  308. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  309. package/test/unit/spec/services/task/contact.ts +33 -0
  310. package/test/unit/spec/services/task/dialer.ts +372 -96
  311. package/test/unit/spec/services/task/digital/Digital.ts +105 -0
  312. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +3433 -0
  313. package/test/unit/spec/services/task/state-machine/guards.ts +839 -0
  314. package/test/unit/spec/services/task/state-machine/types.ts +18 -0
  315. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +3101 -0
  316. package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
  317. package/test/unit/spec/services/task/voice/Voice.ts +1523 -0
  318. package/test/unit/spec/services/task/voice/WebRTC.ts +235 -0
  319. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +459 -0
  320. package/umd/contact-center.min.js +2 -2
  321. package/umd/contact-center.min.js.map +1 -1
  322. package/dist/services/task/index.js +0 -1525
  323. package/dist/services/task/index.js.map +0 -1
  324. package/src/services/task/index.ts +0 -1801
  325. package/test/unit/spec/services/task/index.ts +0 -2184
@@ -0,0 +1,1523 @@
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
+ } from '../../../../../../src/services/task/types';
8
+ import {CC_EVENTS} from '../../../../../../src/services/config/types';
9
+ import {TaskEvent, TaskState} from '../../../../../../src/services/task/state-machine';
10
+ import {computeUIControls} from '../../../../../../src/services/task/state-machine/uiControlsComputer';
11
+ import * as Utils from '../../../../../../src/services/core/Utils';
12
+ import {createTaskData} from '../taskTestUtils';
13
+ import MetricsManager from '../../../../../../src/metrics/MetricsManager';
14
+ import {METRIC_EVENT_NAMES} from '../../../../../../src/metrics/constants';
15
+
16
+ jest.mock('../../../../../../src/services/core/WebexRequest', () => ({
17
+ __esModule: true,
18
+ default: {
19
+ getInstance: () => ({uploadLogs: jest.fn()}),
20
+ },
21
+ }));
22
+
23
+ jest.mock('../../../../../../src/services/core/Utils', () => {
24
+ const actual = jest.requireActual('../../../../../../src/services/core/Utils');
25
+ return {
26
+ __esModule: true,
27
+ ...actual,
28
+ // keep tests deterministic; avoid log upload side-effects on failures
29
+ getErrorDetails: (err: any) => ({error: err}),
30
+ };
31
+ });
32
+
33
+ const dummyContact = {
34
+ hold: jest.fn().mockResolvedValue('held'),
35
+ unHold: jest.fn().mockResolvedValue('resumed'),
36
+ pauseRecording: jest.fn().mockResolvedValue('paused'),
37
+ resumeRecording: jest.fn().mockResolvedValue('resumedRecording'),
38
+ consult: jest.fn().mockResolvedValue('consulted'),
39
+ consultConference: jest.fn().mockResolvedValue('conferenceStarted'),
40
+ dropConferenceParticipant: jest.fn().mockResolvedValue({
41
+ type: 'RoutingMessage',
42
+ trackingId: 'drop-tracking-id',
43
+ data: {},
44
+ }),
45
+ consultTransfer: jest.fn().mockResolvedValue('consultTransferred'),
46
+ cancelTask: jest.fn().mockResolvedValue(undefined),
47
+ } as any;
48
+
49
+ const createBaseData = (overrides: Partial<TaskData> = {}): TaskData =>
50
+ createTaskData({
51
+ interactionId: 'int1',
52
+ mediaResourceId: 'media1',
53
+ interaction: {
54
+ ...(overrides.interaction || {}),
55
+ media: {
56
+ media1: {mediaResourceId: 'media1', isHold: false},
57
+ ...(overrides.interaction as any)?.media,
58
+ },
59
+ },
60
+ ...overrides,
61
+ });
62
+
63
+ const primeConnectedState = (voice: Voice, taskData: TaskData) => {
64
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
65
+ voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
66
+ };
67
+
68
+ const primeHeldState = (voice: Voice, taskData: TaskData) => {
69
+ primeConnectedState(voice, taskData);
70
+ voice.stateMachineService?.send({
71
+ type: TaskEvent.HOLD_INITIATED,
72
+ mediaResourceId: taskData.mediaResourceId,
73
+ });
74
+ voice.stateMachineService?.send({
75
+ type: TaskEvent.HOLD_SUCCESS,
76
+ mediaResourceId: taskData.mediaResourceId,
77
+ });
78
+ };
79
+
80
+ describe('Voice Task', () => {
81
+ beforeEach(() => {
82
+ jest.clearAllMocks();
83
+ });
84
+
85
+ describe('emitTaskOutdialFailed', () => {
86
+ it('emits the failure reason string instead of the Task object', () => {
87
+ const taskData = createBaseData({
88
+ interaction: {
89
+ outboundType: 'OUTDIAL',
90
+ } as any,
91
+ });
92
+ const voice = new Voice(dummyContact, taskData, {});
93
+ const emitSpy = jest.spyOn(voice, 'emit');
94
+
95
+ voice.sendStateMachineEvent({
96
+ type: TaskEvent.OUTBOUND_FAILED,
97
+ taskData,
98
+ reason: 'CUSTOMER_BUSY',
99
+ });
100
+
101
+ const outdialFailedCall = emitSpy.mock.calls.find(
102
+ (call) => call[0] === 'task:outdialFailed'
103
+ );
104
+ expect(outdialFailedCall).toBeDefined();
105
+ expect(outdialFailedCall![1]).toBe('CUSTOMER_BUSY');
106
+ });
107
+
108
+ it('does not emit task:outdialFailed when suppressOutdialFailedPopup is set', () => {
109
+ const taskData = createBaseData({
110
+ interaction: {
111
+ outboundType: 'OUTDIAL',
112
+ } as any,
113
+ suppressOutdialFailedPopup: true,
114
+ });
115
+ const voice = new Voice(dummyContact, taskData, {});
116
+ const emitSpy = jest.spyOn(voice, 'emit');
117
+
118
+ voice.sendStateMachineEvent({
119
+ type: TaskEvent.OUTBOUND_FAILED,
120
+ taskData,
121
+ reason: 'AGENT_ENDS',
122
+ });
123
+
124
+ const outdialFailedCall = emitSpy.mock.calls.find(
125
+ (call) => call[0] === 'task:outdialFailed'
126
+ );
127
+ expect(outdialFailedCall).toBeUndefined();
128
+ });
129
+ });
130
+
131
+ it('hides end and endConsult when disabled', () => {
132
+ const voice = new Voice(dummyContact, createBaseData(), {
133
+ isEndTaskEnabled: false,
134
+ isEndConsultEnabled: false,
135
+ });
136
+ voice.updateTaskData(createBaseData());
137
+ expect(voice.uiControls.main.end.isVisible).toBe(false);
138
+ expect(voice.uiControls.main.endConsult.isVisible).toBe(false);
139
+ });
140
+
141
+ it('calls contact.hold when media is not held', async () => {
142
+ const taskData = createBaseData() as any;
143
+ const voice = new Voice(dummyContact, taskData, {});
144
+ primeConnectedState(voice, taskData);
145
+ await voice.holdResume();
146
+ expect(dummyContact.hold).toHaveBeenCalledWith({
147
+ interactionId: 'int1',
148
+ data: {mediaResourceId: 'media1'},
149
+ });
150
+ });
151
+
152
+ it('calls contact.unHold when media is held', async () => {
153
+ const heldData = createBaseData({
154
+ interaction: {
155
+ media: {media1: {mediaResourceId: 'media1', isHold: true}},
156
+ } as any,
157
+ }) as any;
158
+ const voice = new Voice(dummyContact, heldData, {});
159
+ primeHeldState(voice, heldData);
160
+ await voice.holdResume();
161
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
162
+ interactionId: 'int1',
163
+ data: {mediaResourceId: 'media1'},
164
+ });
165
+ });
166
+
167
+ it('calls contact.unHold when state is held even if task data hold flag is stale', async () => {
168
+ const taskData = createBaseData() as any;
169
+ const voice = new Voice(dummyContact, taskData, {});
170
+ primeHeldState(voice, taskData);
171
+ voice.data.interaction.media.media1.isHold = false;
172
+
173
+ await voice.holdResume();
174
+
175
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
176
+ interactionId: 'int1',
177
+ data: {mediaResourceId: 'media1'},
178
+ });
179
+ });
180
+
181
+ it('calls contact.unHold when state is connected and main media is held', async () => {
182
+ const heldConnectedData = createBaseData({
183
+ interaction: {
184
+ media: {media1: {mediaResourceId: 'media1', isHold: true}},
185
+ } as any,
186
+ }) as any;
187
+ const voice = new Voice(dummyContact, heldConnectedData, {});
188
+ primeConnectedState(voice, heldConnectedData);
189
+
190
+ await voice.holdResume();
191
+
192
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
193
+ interactionId: 'int1',
194
+ data: {mediaResourceId: 'media1'},
195
+ });
196
+ expect(dummyContact.hold).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it('calls contact.unHold when state is conferencing and main media is held', async () => {
200
+ const heldConferenceData = createBaseData({
201
+ interaction: {
202
+ state: 'conference',
203
+ media: {media1: {mediaResourceId: 'media1', isHold: true}},
204
+ } as any,
205
+ }) as any;
206
+ const voice = new Voice(dummyContact, heldConferenceData, {});
207
+ primeConnectedState(voice, heldConferenceData);
208
+ voice.stateMachineService?.send({type: TaskEvent.CONFERENCE_START, taskData: heldConferenceData});
209
+ expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.CONFERENCING);
210
+
211
+ await voice.holdResume();
212
+
213
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
214
+ interactionId: 'int1',
215
+ data: {mediaResourceId: 'media1'},
216
+ });
217
+ });
218
+
219
+ it('uses the main media resource when stale consult media is left in task data', async () => {
220
+ const heldData = createBaseData({
221
+ mediaResourceId: 'main-media',
222
+ interactionId: 'main-int',
223
+ interaction: {
224
+ mainInteractionId: 'main-int',
225
+ media: {
226
+ 'main-int': {mediaResourceId: 'main-media', isHold: true},
227
+ 'consult-media': {mediaResourceId: 'consult-media', isHold: false, mType: 'consult'},
228
+ },
229
+ } as any,
230
+ }) as any;
231
+ const voice = new Voice(dummyContact, heldData, {});
232
+ primeHeldState(voice, heldData);
233
+ voice.data.mediaResourceId = 'consult-media';
234
+
235
+ await voice.holdResume();
236
+
237
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
238
+ interactionId: 'main-int',
239
+ data: {mediaResourceId: 'main-media'},
240
+ });
241
+ expect(dummyContact.hold).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it('pauseRecording() calls contact.pauseRecording', async () => {
245
+ const taskData = createBaseData();
246
+ const voice = new Voice(dummyContact, taskData, {
247
+ isEndTaskEnabled: true,
248
+ isEndConsultEnabled: true,
249
+ });
250
+ primeConnectedState(voice, taskData);
251
+ const res = await voice.pauseRecording();
252
+ expect(dummyContact.pauseRecording).toHaveBeenCalledWith({interactionId: 'int1'});
253
+ });
254
+
255
+ it('resumeRecording() with no payload defaults to autoResumed false', async () => {
256
+ const taskData = createBaseData();
257
+ const voice = new Voice(dummyContact, taskData, {
258
+ isEndTaskEnabled: true,
259
+ isEndConsultEnabled: true,
260
+ });
261
+ primeConnectedState(voice, taskData);
262
+ voice.stateMachineService?.send({type: TaskEvent.PAUSE_RECORDING});
263
+ const res = await voice.resumeRecording();
264
+ expect(dummyContact.resumeRecording).toHaveBeenCalledWith({
265
+ interactionId: 'int1',
266
+ data: {autoResumed: false},
267
+ });
268
+ });
269
+
270
+ it('consult() calls contact.consult with payload', async () => {
271
+ const taskData = createBaseData();
272
+ const voice = new Voice(dummyContact, taskData, {
273
+ isEndTaskEnabled: true,
274
+ isEndConsultEnabled: true,
275
+ });
276
+ primeConnectedState(voice, taskData);
277
+ const payload = {destination: 'agent1', destinationType: 'agent'} as any;
278
+ const res = await voice.consult(payload);
279
+ expect(dummyContact.consult).toHaveBeenCalledWith({
280
+ interactionId: 'int1',
281
+ data: payload,
282
+ });
283
+ });
284
+
285
+ describe('transfer()', () => {
286
+ it('calls contact.consultTransfer for consult transfer to agent', async () => {
287
+ const consultTransferMock = jest.fn().mockResolvedValue('consultedA');
288
+ const dataWithState = createBaseData({
289
+ interaction: {state: 'consulting'} as any,
290
+ });
291
+ const voice = new Voice(
292
+ {...dummyContact, consultTransfer: consultTransferMock},
293
+ dataWithState as any,
294
+ {isEndTaskEnabled: true, isEndConsultEnabled: true}
295
+ );
296
+
297
+ const result = await voice.transfer({
298
+ to: 'destB',
299
+ destinationType: 'agent',
300
+ });
301
+
302
+ expect(consultTransferMock).toHaveBeenCalledWith({
303
+ interactionId: 'int1',
304
+ data: {to: 'destB', destinationType: 'agent'},
305
+ });
306
+ });
307
+
308
+ it('throws if consult transfer to QUEUE but no destAgentId set', async () => {
309
+ const dataWithState = createBaseData({
310
+ destAgentId: undefined,
311
+ interaction: {state: 'consulting'} as any,
312
+ });
313
+ const voice = new Voice(dummyContact, dataWithState as any, {
314
+ isEndTaskEnabled: true,
315
+ isEndConsultEnabled: true,
316
+ });
317
+
318
+ await expect(
319
+ voice.transfer({
320
+ to: 'queue1',
321
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
322
+ })
323
+ ).rejects.toThrow('No agent has accepted this queue consult yet');
324
+ });
325
+
326
+ it('uses data.destAgentId for queue consult transfer', async () => {
327
+ const consultTransferMock = jest.fn().mockResolvedValue('consultedQ');
328
+ const dataWithDest = createBaseData({
329
+ destAgentId: 'agentD',
330
+ interaction: {state: 'consulting'} as any,
331
+ });
332
+ const voice = new Voice(
333
+ {...dummyContact, consultTransfer: consultTransferMock},
334
+ dataWithDest as any,
335
+ {isEndTaskEnabled: true, isEndConsultEnabled: true}
336
+ );
337
+
338
+ const result = await voice.transfer({
339
+ to: 'queueX',
340
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
341
+ });
342
+
343
+ expect(consultTransferMock).toHaveBeenCalledWith({
344
+ interactionId: 'int1',
345
+ data: {
346
+ to: 'agentD',
347
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT,
348
+ },
349
+ });
350
+ });
351
+
352
+ it('uses preserved consult destination from task data for queue consult transfer', async () => {
353
+ const consultTransferMock = jest.fn().mockResolvedValue('consultedQ');
354
+ const dataWithState = createBaseData({
355
+ destAgentId: undefined,
356
+ destinationType: undefined,
357
+ interaction: {state: 'consulting'} as any,
358
+ });
359
+ const voice = new Voice(
360
+ {...dummyContact, consultTransfer: consultTransferMock},
361
+ dataWithState as any,
362
+ {isEndTaskEnabled: true, isEndConsultEnabled: true}
363
+ );
364
+
365
+ primeConnectedState(voice, dataWithState);
366
+ voice.updateTaskData(
367
+ createBaseData({
368
+ destAgentId: 'agent-preserved',
369
+ interaction: {state: 'consulting'} as any,
370
+ }) as any
371
+ );
372
+
373
+ await voice.transfer({
374
+ to: 'queueX',
375
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
376
+ });
377
+
378
+ expect(consultTransferMock).toHaveBeenCalledWith({
379
+ interactionId: 'int1',
380
+ data: {
381
+ to: 'agent-preserved',
382
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT,
383
+ },
384
+ });
385
+ });
386
+ });
387
+
388
+ describe('endConsult()', () => {
389
+ it('calls contact.consultEnd with correct payload', async () => {
390
+ const consultEndMock = jest.fn().mockResolvedValue('endedC');
391
+ const voice = new Voice({...dummyContact, consultEnd: consultEndMock}, createBaseData(), {
392
+ isEndTaskEnabled: true,
393
+ isEndConsultEnabled: true,
394
+ });
395
+ const payload = {isConsult: true, queueId: 'q1', taskId: 't1'};
396
+ const result = await voice.endConsult(payload);
397
+
398
+ expect(consultEndMock).toHaveBeenCalledWith({
399
+ interactionId: 'int1',
400
+ data: payload,
401
+ });
402
+ expect(result).toBe('endedC');
403
+ });
404
+
405
+ it('uses mainInteractionId when present for consultEnd request', async () => {
406
+ const consultEndMock = jest.fn().mockResolvedValue('endedC');
407
+ const taskData = createBaseData({
408
+ interactionId: 'child-int',
409
+ interaction: {
410
+ mainInteractionId: 'main-int',
411
+ } as any,
412
+ });
413
+ const voice = new Voice({...dummyContact, consultEnd: consultEndMock}, taskData, {
414
+ isEndTaskEnabled: true,
415
+ isEndConsultEnabled: true,
416
+ });
417
+
418
+ await voice.endConsult({isConsult: true} as any);
419
+
420
+ expect(consultEndMock).toHaveBeenCalledWith({
421
+ interactionId: 'main-int',
422
+ data: {isConsult: true},
423
+ });
424
+ });
425
+ });
426
+
427
+ describe('UI controls for AGENT_CONTACT_ASSIGNED', () => {
428
+ it('shows main controls and hides accept/decline on AGENT_CONTACT_ASSIGNED', () => {
429
+ const data: any = {...createBaseData(), type: CC_EVENTS.AGENT_CONTACT_ASSIGNED};
430
+ const voice = new Voice(dummyContact, data, {
431
+ isEndTaskEnabled: true,
432
+ isEndConsultEnabled: false,
433
+ });
434
+
435
+ voice.updateTaskData(data);
436
+ primeConnectedState(voice, data);
437
+
438
+ expect(voice.uiControls.main.accept.isVisible).toBe(false);
439
+ expect(voice.uiControls.main.decline.isVisible).toBe(false);
440
+ expect(voice.uiControls.main.hold.isVisible).toBe(true);
441
+ expect(voice.uiControls.main.transfer.isVisible).toBe(true);
442
+ expect(voice.uiControls.main.consult.isVisible).toBe(true);
443
+ expect(voice.uiControls.main.recording.isVisible).toBe(true);
444
+ expect(voice.uiControls.main.end.isVisible).toBe(true);
445
+ expect(voice.uiControls.main.endConsult.isVisible).toBe(false);
446
+ });
447
+ });
448
+
449
+ describe('state machine derived controls', () => {
450
+ it('keeps uiControls in sync with state machine context', () => {
451
+ const taskData = createBaseData();
452
+ const voice = new Voice(dummyContact, taskData, {});
453
+ const initialSnapshot = voice.stateMachineService?.getSnapshot();
454
+ const initialExpected = computeUIControls(
455
+ initialSnapshot?.value as TaskState,
456
+ initialSnapshot?.context as any,
457
+ voice.data
458
+ );
459
+ expect(voice.uiControls).toEqual(initialExpected);
460
+
461
+ voice.updateTaskData(taskData);
462
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
463
+ voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
464
+
465
+ const snapshot = voice.stateMachineService?.getSnapshot();
466
+ const expected = computeUIControls(
467
+ snapshot?.value as TaskState,
468
+ snapshot?.context as any,
469
+ voice.data
470
+ );
471
+ expect(voice.uiControls).toEqual(expected);
472
+ });
473
+ });
474
+
475
+ describe('recording operations', () => {
476
+ const buildRecordingData = (recordingOverrides: Record<string, any>) =>
477
+ createBaseData({
478
+ interaction: {
479
+ callProcessingDetails: recordingOverrides,
480
+ } as any,
481
+ });
482
+
483
+ it('throws when pauseRecording is invoked without active recording', async () => {
484
+ const voice = new Voice(dummyContact, createBaseData(), {});
485
+ await expect(voice.pauseRecording()).rejects.toThrow(
486
+ 'Recording is not active or already paused'
487
+ );
488
+ expect(dummyContact.pauseRecording).not.toHaveBeenCalled();
489
+ });
490
+
491
+ it('pauses recording when state machine context indicates active recording', async () => {
492
+ const taskData = buildRecordingData({recordInProgress: true});
493
+ const voice = new Voice(dummyContact, taskData, {});
494
+ primeConnectedState(voice, taskData);
495
+
496
+ await voice.pauseRecording();
497
+ expect(dummyContact.pauseRecording).toHaveBeenCalledWith({interactionId: 'int1'});
498
+ });
499
+
500
+ it('throws if resumeRecording is invoked while recording is not paused', async () => {
501
+ const taskData = buildRecordingData({recordInProgress: true});
502
+ const voice = new Voice(dummyContact, taskData, {});
503
+ primeConnectedState(voice, taskData);
504
+
505
+ await expect(voice.resumeRecording()).rejects.toThrow('Recording is not paused');
506
+ expect(dummyContact.resumeRecording).not.toHaveBeenCalled();
507
+ });
508
+
509
+ it('resumes recording when context shows paused recording', async () => {
510
+ const taskData = buildRecordingData({recordInProgress: true});
511
+ const voice = new Voice(dummyContact, taskData, {});
512
+ primeConnectedState(voice, taskData);
513
+ voice.stateMachineService?.send({type: TaskEvent.PAUSE_RECORDING});
514
+
515
+ await voice.resumeRecording();
516
+ expect(dummyContact.resumeRecording).toHaveBeenCalledWith({
517
+ interactionId: 'int1',
518
+ data: {autoResumed: false},
519
+ });
520
+ });
521
+ });
522
+
523
+ describe('switchCall()', () => {
524
+ const buildConsultingTaskData = () =>
525
+ createBaseData({
526
+ agentId: 'agent1',
527
+ consultMediaResourceId: 'consultMedia1',
528
+ interaction: {
529
+ media: {
530
+ media1: {
531
+ mediaResourceId: 'media1',
532
+ isHold: true,
533
+ mType: 'mainCall',
534
+ participants: ['agent1', 'customer1'],
535
+ },
536
+ consultMedia1: {
537
+ mediaResourceId: 'consultMedia1',
538
+ isHold: false,
539
+ mType: 'consult',
540
+ participants: ['agent1', 'agent2'],
541
+ },
542
+ },
543
+ participants: {
544
+ agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
545
+ agent2: {id: 'agent2', pType: 'Agent', type: 'Agent', hasLeft: false},
546
+ customer1: {id: 'customer1', pType: 'Customer', type: 'Customer', hasLeft: false},
547
+ },
548
+ } as any,
549
+ });
550
+
551
+ const primeConsultingState = (voice: Voice, taskData: TaskData) => {
552
+ primeConnectedState(voice, taskData);
553
+ voice.stateMachineService?.send({
554
+ type: TaskEvent.CONSULT,
555
+ destination: 'agent2',
556
+ destinationType: 'agent' as any,
557
+ });
558
+ voice.stateMachineService?.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
559
+ };
560
+
561
+ it('switches from consult leg to main leg by unholding main media', async () => {
562
+ const taskData = buildConsultingTaskData();
563
+ const voice = new Voice(dummyContact, taskData, {});
564
+ primeConsultingState(voice, taskData);
565
+
566
+ await voice.switchCall();
567
+
568
+ expect(dummyContact.unHold).toHaveBeenCalledTimes(1);
569
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
570
+ interactionId: 'int1',
571
+ data: {mediaResourceId: 'media1'},
572
+ });
573
+ expect(dummyContact.hold).not.toHaveBeenCalled();
574
+ });
575
+
576
+ it('switches from main leg to consult leg by holding main media', async () => {
577
+ const taskData = buildConsultingTaskData();
578
+ taskData.interaction.media.media1.isHold = false;
579
+ taskData.interaction.media.consultMedia1.isHold = true;
580
+ const voice = new Voice(dummyContact, taskData, {});
581
+ primeConsultingState(voice, taskData);
582
+ voice.stateMachineService?.send({type: TaskEvent.SWITCH_TO_MAIN_CALL});
583
+
584
+ await voice.switchCall();
585
+
586
+ expect(dummyContact.hold).toHaveBeenCalledTimes(1);
587
+ expect(dummyContact.hold).toHaveBeenCalledWith({
588
+ interactionId: 'int1',
589
+ data: {mediaResourceId: 'media1'},
590
+ });
591
+ expect(dummyContact.unHold).not.toHaveBeenCalled();
592
+ });
593
+ });
594
+
595
+ describe('consultConference()', () => {
596
+ afterEach(() => {
597
+ jest.restoreAllMocks();
598
+ });
599
+
600
+ it('uses cached consult destination when task data destination is cleared', async () => {
601
+ const taskData = createBaseData({
602
+ agentId: 'agent1',
603
+ destAgentId: undefined,
604
+ destinationType: undefined,
605
+ interaction: {
606
+ media: {
607
+ media1: {mediaResourceId: 'media1', isHold: true},
608
+ },
609
+ participants: {
610
+ agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
611
+ },
612
+ } as any,
613
+ });
614
+
615
+ const voice = new Voice(dummyContact, taskData, {});
616
+ primeConnectedState(voice, taskData);
617
+ voice.stateMachineService?.send({
618
+ type: TaskEvent.CONSULT,
619
+ destination: 'agent2',
620
+ destAgentId: 'agent2',
621
+ destinationType: 'agent' as any,
622
+ });
623
+ voice.updateTaskData(
624
+ createBaseData({
625
+ agentId: 'agent1',
626
+ destAgentId: undefined,
627
+ destinationType: undefined,
628
+ interaction: {state: 'consulting'} as any,
629
+ }) as any
630
+ );
631
+
632
+ await voice.consultConference();
633
+
634
+ expect(dummyContact.consultConference).toHaveBeenCalledWith({
635
+ interactionId: 'int1',
636
+ data: expect.objectContaining({
637
+ to: 'agent2',
638
+ destinationType: 'agent',
639
+ }),
640
+ });
641
+ });
642
+
643
+ it('falls back to derived destination when context and task destination are unavailable', async () => {
644
+ jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValueOnce('derivedAgent');
645
+ jest.spyOn(Utils, 'calculateDestType').mockReturnValueOnce('agent');
646
+
647
+ const taskData = createBaseData({
648
+ agentId: 'agent1',
649
+ destAgentId: undefined,
650
+ destinationType: undefined,
651
+ interaction: {
652
+ media: {
653
+ media1: {mediaResourceId: 'media1', isHold: true},
654
+ },
655
+ participants: {
656
+ agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
657
+ },
658
+ } as any,
659
+ });
660
+
661
+ const voice = new Voice(dummyContact, taskData, {});
662
+ primeConnectedState(voice, taskData);
663
+ voice.stateMachineService?.send({
664
+ type: TaskEvent.CONSULT,
665
+ destination: '',
666
+ destAgentId: undefined,
667
+ destinationType: 'agent' as any,
668
+ });
669
+ voice.stateMachineService?.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
670
+
671
+ await voice.consultConference();
672
+
673
+ expect(dummyContact.consultConference).toHaveBeenCalledWith({
674
+ interactionId: 'int1',
675
+ data: expect.objectContaining({
676
+ to: 'derivedAgent',
677
+ }),
678
+ });
679
+ });
680
+ });
681
+
682
+ describe('dropConferenceParticipant()', () => {
683
+ afterEach(() => {
684
+ jest.restoreAllMocks();
685
+ });
686
+
687
+ it.each([
688
+ ['missing payload', undefined],
689
+ ['missing participantId', {}],
690
+ ['non-string participantId', {participantId: 42}],
691
+ ['blank participantId', {participantId: ' '}],
692
+ ])('rejects %s before registering a request', async (_label, payload) => {
693
+ const taskData = createBaseData();
694
+ const voice = new Voice(dummyContact, taskData, {});
695
+ const timeEventSpy = jest.spyOn(MetricsManager.getInstance(), 'timeEvent');
696
+
697
+ await expect(voice.dropConferenceParticipant(payload as any)).rejects.toThrow(
698
+ 'participantId must be a non-empty string'
699
+ );
700
+
701
+ expect(dummyContact.dropConferenceParticipant).not.toHaveBeenCalled();
702
+ expect(timeEventSpy).not.toHaveBeenCalled();
703
+ });
704
+
705
+ it('uses the latest main interaction ID and records PII-safe success telemetry', async () => {
706
+ const participantId = '+1/participant-secret';
707
+ const taskData = createBaseData({
708
+ interaction: {mainInteractionId: 'main-interaction-id'} as any,
709
+ });
710
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'current-agent-id');
711
+ const metricsManager = MetricsManager.getInstance();
712
+ const timeEventSpy = jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
713
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
714
+ const infoSpy = jest.spyOn(LoggerProxy, 'info').mockImplementation();
715
+ const logSpy = jest.spyOn(LoggerProxy, 'log').mockImplementation();
716
+
717
+ await voice.dropConferenceParticipant({participantId});
718
+
719
+ expect(dummyContact.dropConferenceParticipant).toHaveBeenCalledWith({
720
+ interactionId: 'main-interaction-id',
721
+ participantId,
722
+ });
723
+ expect(timeEventSpy).toHaveBeenCalledWith([
724
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_SUCCESS,
725
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
726
+ ]);
727
+ expect(trackEventSpy).toHaveBeenCalledWith(
728
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_SUCCESS,
729
+ expect.objectContaining({
730
+ taskId: 'int1',
731
+ requestInteractionId: 'main-interaction-id',
732
+ agentId: 'current-agent-id',
733
+ }),
734
+ ['operational', 'behavioral', 'business']
735
+ );
736
+
737
+ const telemetryAndLogs = JSON.stringify([
738
+ timeEventSpy.mock.calls,
739
+ trackEventSpy.mock.calls,
740
+ infoSpy.mock.calls,
741
+ logSpy.mock.calls,
742
+ ]);
743
+ expect(telemetryAndLogs).not.toContain(participantId);
744
+ });
745
+
746
+ it('records PII-safe failure telemetry and rethrows the AQM error', async () => {
747
+ const participantId = '+1/failure-secret';
748
+ const failure = {
749
+ details: {
750
+ type: 'ParticipantDropConferenceFailed',
751
+ trackingId: 'failure-tracking-id',
752
+ data: {reason: participantId},
753
+ },
754
+ };
755
+ dummyContact.dropConferenceParticipant.mockRejectedValueOnce(failure);
756
+ const taskData = createBaseData({
757
+ interaction: {mainInteractionId: 'main-interaction-id'} as any,
758
+ });
759
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'current-agent-id');
760
+ const metricsManager = MetricsManager.getInstance();
761
+ jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
762
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
763
+ const errorSpy = jest.spyOn(LoggerProxy, 'error').mockImplementation();
764
+
765
+ await expect(voice.dropConferenceParticipant({participantId})).rejects.toBe(failure);
766
+
767
+ expect(trackEventSpy).toHaveBeenCalledWith(
768
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
769
+ expect.objectContaining({
770
+ taskId: 'int1',
771
+ requestInteractionId: 'main-interaction-id',
772
+ agentId: 'current-agent-id',
773
+ }),
774
+ ['operational', 'behavioral', 'business']
775
+ );
776
+ expect(errorSpy).toHaveBeenCalledWith('Failed to drop conference participant', {
777
+ module: 'cc',
778
+ method: 'dropConferenceParticipant',
779
+ trackingId: 'failure-tracking-id',
780
+ interactionId: 'main-interaction-id',
781
+ });
782
+ expect(JSON.stringify([trackEventSpy.mock.calls, errorSpy.mock.calls])).not.toContain(
783
+ participantId
784
+ );
785
+ });
786
+
787
+ it.each([
788
+ ['null', null],
789
+ ['undefined', undefined],
790
+ ['string', 'primitive failure'],
791
+ ['number', 503],
792
+ ])('records failure telemetry and preserves a %s rejection', async (_label, failure) => {
793
+ dummyContact.dropConferenceParticipant.mockRejectedValueOnce(failure);
794
+ const voice = new Voice(
795
+ dummyContact,
796
+ createBaseData(),
797
+ {},
798
+ undefined,
799
+ 'current-agent-id'
800
+ );
801
+ const metricsManager = MetricsManager.getInstance();
802
+ const failureFieldsSpy = jest.spyOn(
803
+ MetricsManager,
804
+ 'getCommonTrackingFieldForAQMResponseFailed'
805
+ );
806
+ jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
807
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
808
+ const errorSpy = jest.spyOn(LoggerProxy, 'error').mockImplementation();
809
+
810
+ await expect(
811
+ voice.dropConferenceParticipant({participantId: 'participant-id'})
812
+ ).rejects.toBe(failure);
813
+
814
+ expect(failureFieldsSpy).toHaveBeenCalledWith({});
815
+ expect(trackEventSpy).toHaveBeenCalledWith(
816
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
817
+ expect.objectContaining({
818
+ taskId: 'int1',
819
+ requestInteractionId: 'int1',
820
+ agentId: 'current-agent-id',
821
+ }),
822
+ ['operational', 'behavioral', 'business']
823
+ );
824
+ expect(errorSpy).toHaveBeenCalledWith('Failed to drop conference participant', {
825
+ module: 'cc',
826
+ method: 'dropConferenceParticipant',
827
+ trackingId: undefined,
828
+ interactionId: 'int1',
829
+ });
830
+ });
831
+ });
832
+
833
+ describe('setEnableWxBetterTogether', () => {
834
+ it('updates runtime flag, uiControlConfig, and emits ui control updates', () => {
835
+ const voice = new Voice(dummyContact, createBaseData(), {enableWxBetterTogether: true});
836
+ const emitSpy = jest.spyOn(voice, 'emit');
837
+
838
+ voice.setEnableWxBetterTogether(false);
839
+
840
+ expect(voice['enableWxBetterTogether']).toBe(false);
841
+ expect(voice['uiControlConfig'].enableWxBetterTogether).toBe(false);
842
+ expect(emitSpy).toHaveBeenCalledWith(
843
+ TASK_EVENTS.TASK_UI_CONTROLS_UPDATED,
844
+ expect.any(Object)
845
+ );
846
+ });
847
+ });
848
+
849
+ describe('applyWxAppMuteStateFromSync', () => {
850
+ const wxAppParticipant = {
851
+ deviceType: 'wxApp',
852
+ deviceId: 'device-id-1',
853
+ deviceCallId: 'call-id-1',
854
+ };
855
+
856
+ const makeWxAppTaskData = () =>
857
+ createBaseData({
858
+ agentId: 'agent-1',
859
+ interaction: {
860
+ participants: {
861
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
862
+ },
863
+ } as any,
864
+ });
865
+
866
+ it('emits TASK_WXAPP_MUTE_STATE_UPDATED when callId matches via endsWith', () => {
867
+ const taskData = makeWxAppTaskData();
868
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
869
+ primeConnectedState(voice, taskData);
870
+ const emitSpy = jest.spyOn(voice, 'emit');
871
+
872
+ voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
873
+
874
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {muted: true});
875
+ });
876
+
877
+ it('no-ops when callId does not match active call', () => {
878
+ const taskData = makeWxAppTaskData();
879
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
880
+ primeConnectedState(voice, taskData);
881
+ const emitSpy = jest.spyOn(voice, 'emit');
882
+
883
+ voice.applyWxAppMuteStateFromSync('other-call-id', true);
884
+
885
+ expect(emitSpy).not.toHaveBeenCalledWith(
886
+ TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
887
+ expect.anything()
888
+ );
889
+ });
890
+
891
+ it('no-ops when enableWxBetterTogether is false', () => {
892
+ const taskData = makeWxAppTaskData();
893
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: false});
894
+ primeConnectedState(voice, taskData);
895
+ const emitSpy = jest.spyOn(voice, 'emit');
896
+
897
+ voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
898
+
899
+ expect(emitSpy).not.toHaveBeenCalledWith(
900
+ TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
901
+ expect.anything()
902
+ );
903
+ });
904
+
905
+ it('no-ops when muted state is unchanged', () => {
906
+ const taskData = makeWxAppTaskData();
907
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
908
+ primeConnectedState(voice, taskData);
909
+ const emitSpy = jest.spyOn(voice, 'emit');
910
+
911
+ voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
912
+ emitSpy.mockClear();
913
+
914
+ voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
915
+
916
+ expect(emitSpy).not.toHaveBeenCalledWith(
917
+ TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
918
+ expect.anything()
919
+ );
920
+ });
921
+ });
922
+
923
+ describe('syncWxAppMuteFromCallDetails', () => {
924
+ const wxAppParticipant = {
925
+ deviceType: 'wxApp',
926
+ deviceId: 'device-id-1',
927
+ deviceCallId: 'call-id-1',
928
+ };
929
+
930
+ const makeWxAppTaskData = () =>
931
+ createBaseData({
932
+ agentId: 'agent-1',
933
+ interaction: {
934
+ participants: {
935
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
936
+ },
937
+ } as any,
938
+ });
939
+
940
+ it('seeds mute state from telephony GET call details on engaged wxApp call', async () => {
941
+ const mockSvc = {
942
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
943
+ };
944
+ const taskData = makeWxAppTaskData();
945
+ const voice = new Voice(dummyContact, taskData, {
946
+ enableWxBetterTogether: true,
947
+ answerCallOnWebexService: mockSvc as any,
948
+ });
949
+ primeConnectedState(voice, taskData);
950
+ const emitSpy = jest.spyOn(voice, 'emit');
951
+
952
+ await voice.syncWxAppMuteFromCallDetails();
953
+
954
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
955
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
956
+ muted: true,
957
+ });
958
+ expect(voice.getWxAppMuted()).toBe(true);
959
+ });
960
+
961
+ it('returns resolved mute state from syncWxAppMuteFromCallDetails', async () => {
962
+ const mockSvc = {
963
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
964
+ };
965
+ const taskData = makeWxAppTaskData();
966
+ const voice = new Voice(dummyContact, taskData, {
967
+ enableWxBetterTogether: true,
968
+ answerCallOnWebexService: mockSvc as any,
969
+ });
970
+ primeConnectedState(voice, taskData);
971
+
972
+ const result = await voice.syncWxAppMuteFromCallDetails();
973
+
974
+ expect(result).toBe(true);
975
+ expect(voice.getWxAppMuted()).toBe(true);
976
+ });
977
+
978
+ it('seeds mute via participant deviceCallId when state machine is still IDLE', async () => {
979
+ const mockSvc = {
980
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
981
+ };
982
+ const taskData = makeWxAppTaskData();
983
+ const voice = new Voice(dummyContact, taskData, {
984
+ enableWxBetterTogether: true,
985
+ answerCallOnWebexService: mockSvc as any,
986
+ });
987
+ const emitSpy = jest.spyOn(voice, 'emit');
988
+
989
+ const result = await voice.syncWxAppMuteFromCallDetails();
990
+
991
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
992
+ expect(result).toBe(true);
993
+ expect(voice.getWxAppMuted()).toBe(true);
994
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
995
+ muted: true,
996
+ });
997
+ });
998
+
999
+ it('no-ops when enableWxBetterTogether is false', async () => {
1000
+ const mockSvc = {getCallDetails: jest.fn()};
1001
+ const taskData = makeWxAppTaskData();
1002
+ const voice = new Voice(dummyContact, taskData, {
1003
+ enableWxBetterTogether: false,
1004
+ answerCallOnWebexService: mockSvc as any,
1005
+ });
1006
+ primeConnectedState(voice, taskData);
1007
+
1008
+ await voice.syncWxAppMuteFromCallDetails();
1009
+
1010
+ expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
1011
+ });
1012
+
1013
+ it('syncs mute state after task assignment once CONNECTED (onTaskAssigned)', async () => {
1014
+ const mockSvc = {
1015
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
1016
+ };
1017
+ const taskData = makeWxAppTaskData();
1018
+ const voice = new Voice(dummyContact, taskData, {
1019
+ enableWxBetterTogether: true,
1020
+ answerCallOnWebexService: mockSvc as any,
1021
+ });
1022
+
1023
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1024
+ expect(voice.getWxAppMuted()).toBe(false);
1025
+ voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
1026
+
1027
+ await new Promise<void>((resolve) => {
1028
+ setImmediate(resolve);
1029
+ });
1030
+
1031
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1032
+ expect(voice.getWxAppMuted()).toBe(true);
1033
+ });
1034
+
1035
+ it('seeds mute on ASSIGN when wxApp offer was accepted on another session', async () => {
1036
+ const mockSvc = {
1037
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1038
+ };
1039
+ const taskData = makeWxAppTaskData();
1040
+ const voice = new Voice(dummyContact, taskData, {
1041
+ enableWxBetterTogether: true,
1042
+ answerCallOnWebexService: mockSvc as any,
1043
+ });
1044
+
1045
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1046
+ expect(voice['uiControlConfig']?.wxAppAnswerPending).toBeFalsy();
1047
+ voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
1048
+
1049
+ await new Promise<void>((resolve) => {
1050
+ setImmediate(resolve);
1051
+ });
1052
+
1053
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1054
+ });
1055
+
1056
+ it('syncs mute state after task hydrate (onTaskHydrated)', async () => {
1057
+ const mockSvc = {
1058
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
1059
+ };
1060
+ const taskData = makeWxAppTaskData();
1061
+ const voice = new Voice(dummyContact, taskData, {
1062
+ enableWxBetterTogether: true,
1063
+ answerCallOnWebexService: mockSvc as any,
1064
+ });
1065
+ primeConnectedState(voice, taskData);
1066
+ const emitSpy = jest.spyOn(voice, 'emit');
1067
+
1068
+ voice.onTaskHydrated();
1069
+ await voice.syncWxAppMuteFromCallDetails();
1070
+
1071
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1072
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
1073
+ muted: true,
1074
+ });
1075
+ });
1076
+
1077
+ it('preserves wxAppAnswerPending on hydrate while OFFERED and answer is pending', async () => {
1078
+ const mockSvc = {
1079
+ answerCall: jest.fn().mockResolvedValue(undefined),
1080
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1081
+ };
1082
+ const taskData = makeWxAppTaskData();
1083
+ const voice = new Voice(dummyContact, taskData, {
1084
+ enableWxBetterTogether: true,
1085
+ answerCallOnWebexService: mockSvc as any,
1086
+ });
1087
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1088
+
1089
+ await voice.accept();
1090
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
1091
+
1092
+ voice.onTaskHydrated();
1093
+
1094
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
1095
+ });
1096
+
1097
+ it('clears wxAppAnswerPending on hydrate when task is no longer OFFERED', async () => {
1098
+ const mockSvc = {
1099
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1100
+ };
1101
+ const taskData = makeWxAppTaskData();
1102
+ const voice = new Voice(dummyContact, taskData, {
1103
+ enableWxBetterTogether: true,
1104
+ answerCallOnWebexService: mockSvc as any,
1105
+ });
1106
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1107
+ voice.uiControlConfig = {...voice.uiControlConfig, wxAppAnswerPending: true};
1108
+ primeConnectedState(voice, taskData);
1109
+
1110
+ voice.onTaskHydrated();
1111
+
1112
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(false);
1113
+ });
1114
+
1115
+ it('coalesces parallel syncWxAppMuteFromCallDetails into one getCallDetails', async () => {
1116
+ let resolveGetCallDetails: (value: {muted: boolean}) => void;
1117
+ const getCallDetailsPromise = new Promise<{muted: boolean}>((resolve) => {
1118
+ resolveGetCallDetails = resolve;
1119
+ });
1120
+ const mockSvc = {
1121
+ getCallDetails: jest.fn().mockReturnValue(getCallDetailsPromise),
1122
+ };
1123
+ const taskData = makeWxAppTaskData();
1124
+ const voice = new Voice(dummyContact, taskData, {
1125
+ enableWxBetterTogether: true,
1126
+ answerCallOnWebexService: mockSvc as any,
1127
+ });
1128
+ primeConnectedState(voice, taskData);
1129
+
1130
+ const sync1 = voice.syncWxAppMuteFromCallDetails();
1131
+ const sync2 = voice.syncWxAppMuteFromCallDetails();
1132
+
1133
+ resolveGetCallDetails!({muted: true});
1134
+ const [result1, result2] = await Promise.all([sync1, sync2]);
1135
+
1136
+ expect(result1).toBe(true);
1137
+ expect(result2).toBe(true);
1138
+ expect(mockSvc.getCallDetails).toHaveBeenCalledTimes(1);
1139
+ });
1140
+
1141
+ it('issues a new getCallDetails on sequential syncWxAppMuteFromCallDetails after the first completes', async () => {
1142
+ const mockSvc = {
1143
+ getCallDetails: jest
1144
+ .fn()
1145
+ .mockResolvedValueOnce({muted: false})
1146
+ .mockResolvedValueOnce({muted: true}),
1147
+ };
1148
+ const taskData = makeWxAppTaskData();
1149
+ const voice = new Voice(dummyContact, taskData, {
1150
+ enableWxBetterTogether: true,
1151
+ answerCallOnWebexService: mockSvc as any,
1152
+ });
1153
+ primeConnectedState(voice, taskData);
1154
+
1155
+ await voice.syncWxAppMuteFromCallDetails();
1156
+ await voice.syncWxAppMuteFromCallDetails();
1157
+
1158
+ expect(mockSvc.getCallDetails).toHaveBeenCalledTimes(2);
1159
+ });
1160
+
1161
+ it('skips mute GET for terminated wxApp outdial with deviceCallId', async () => {
1162
+ const mockSvc = {getCallDetails: jest.fn()};
1163
+ const taskData = createBaseData({
1164
+ agentId: 'agent-1',
1165
+ interaction: {
1166
+ outboundType: 'OUTDIAL',
1167
+ isTerminated: true,
1168
+ participants: {
1169
+ 'agent-1': {
1170
+ id: 'agent-1',
1171
+ deviceType: 'wxApp',
1172
+ deviceId: 'device-id-1',
1173
+ deviceCallId: 'callhalf-dead',
1174
+ },
1175
+ },
1176
+ } as any,
1177
+ });
1178
+ const voice = new Voice(dummyContact, taskData, {
1179
+ enableWxBetterTogether: true,
1180
+ answerCallOnWebexService: mockSvc as any,
1181
+ });
1182
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1183
+
1184
+ await voice.syncWxAppMuteFromCallDetails();
1185
+
1186
+ expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
1187
+ });
1188
+
1189
+ it('skips mute GET for pre-accept wxApp OFFERED offer', async () => {
1190
+ const mockSvc = {getCallDetails: jest.fn()};
1191
+ const wxAppParticipant = {
1192
+ deviceType: 'wxApp',
1193
+ deviceId: 'device-id-1',
1194
+ deviceCallId: 'call-id-1',
1195
+ };
1196
+ const taskData = createBaseData({
1197
+ agentId: 'agent-1',
1198
+ interaction: {
1199
+ outboundType: 'OUTDIAL',
1200
+ participants: {
1201
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1202
+ },
1203
+ } as any,
1204
+ });
1205
+ const voice = new Voice(dummyContact, taskData, {
1206
+ enableWxBetterTogether: true,
1207
+ answerCallOnWebexService: mockSvc as any,
1208
+ });
1209
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1210
+
1211
+ await voice.syncWxAppMuteFromCallDetails();
1212
+
1213
+ expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
1214
+ });
1215
+
1216
+ it('still calls mute GET for post-accept OFFERED wxApp offer', async () => {
1217
+ const mockSvc = {
1218
+ answerCall: jest.fn().mockResolvedValue(undefined),
1219
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1220
+ };
1221
+ const wxAppParticipant = {
1222
+ deviceType: 'wxApp',
1223
+ deviceId: 'device-id-1',
1224
+ deviceCallId: 'call-id-1',
1225
+ };
1226
+ const taskData = createBaseData({
1227
+ agentId: 'agent-1',
1228
+ interaction: {
1229
+ outboundType: 'OUTDIAL',
1230
+ participants: {
1231
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1232
+ },
1233
+ } as any,
1234
+ });
1235
+ const voice = new Voice(dummyContact, taskData, {
1236
+ enableWxBetterTogether: true,
1237
+ answerCallOnWebexService: mockSvc as any,
1238
+ });
1239
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1240
+ await voice.accept();
1241
+
1242
+ expect(voice.uiControlConfig.wxAppAcceptInFlight).toBe(false);
1243
+ await voice.syncWxAppMuteFromCallDetails();
1244
+
1245
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1246
+ });
1247
+
1248
+ it('does not log error for expected wxApp call-not-found on mute sync', async () => {
1249
+ const errorSpy = jest.spyOn(LoggerProxy, 'error');
1250
+ const mockSvc = {
1251
+ getCallDetails: jest.fn().mockRejectedValue({status: 400, message: 'Call not found'}),
1252
+ };
1253
+ const wxAppParticipant = {
1254
+ deviceType: 'wxApp',
1255
+ deviceId: 'device-id-1',
1256
+ deviceCallId: 'call-id-1',
1257
+ };
1258
+ const taskData = createBaseData({
1259
+ agentId: 'agent-1',
1260
+ interaction: {
1261
+ participants: {
1262
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1263
+ },
1264
+ } as any,
1265
+ });
1266
+ const voice = new Voice(dummyContact, taskData, {
1267
+ enableWxBetterTogether: true,
1268
+ answerCallOnWebexService: mockSvc as any,
1269
+ });
1270
+ primeConnectedState(voice, taskData);
1271
+
1272
+ await voice.syncWxAppMuteFromCallDetails();
1273
+
1274
+ expect(mockSvc.getCallDetails).toHaveBeenCalled();
1275
+ expect(errorSpy).not.toHaveBeenCalled();
1276
+ errorSpy.mockRestore();
1277
+ });
1278
+ });
1279
+
1280
+ describe('decline', () => {
1281
+ const wxAppParticipant = {
1282
+ deviceType: 'wxApp',
1283
+ deviceId: 'device-id-1',
1284
+ deviceCallId: 'call-id-1',
1285
+ };
1286
+
1287
+ const makeWxAppOutdialTaskData = () =>
1288
+ createBaseData({
1289
+ agentId: 'agent-1',
1290
+ interaction: {
1291
+ outboundType: 'OUTDIAL',
1292
+ participants: {
1293
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1294
+ },
1295
+ } as any,
1296
+ });
1297
+
1298
+ it('cancels wxApp outdial via contact.cancelTask', async () => {
1299
+ const taskData = makeWxAppOutdialTaskData();
1300
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1301
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1302
+
1303
+ await voice.decline();
1304
+
1305
+ expect(dummyContact.cancelTask).toHaveBeenCalledWith({interactionId: 'int1'});
1306
+ });
1307
+
1308
+ it('clears wxAppAnswerPending when declining outdial', async () => {
1309
+ const mockSvc = {
1310
+ answerCall: jest.fn().mockResolvedValue(undefined),
1311
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1312
+ };
1313
+ const taskData = makeWxAppOutdialTaskData();
1314
+ const voice = new Voice(dummyContact, taskData, {
1315
+ enableWxBetterTogether: true,
1316
+ answerCallOnWebexService: mockSvc as any,
1317
+ });
1318
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1319
+
1320
+ await voice.accept();
1321
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
1322
+ expect(voice.uiControlConfig.wxAppAcceptInFlight).toBe(false);
1323
+
1324
+ await voice.decline();
1325
+
1326
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(false);
1327
+ expect(dummyContact.cancelTask).toHaveBeenCalledWith({interactionId: 'int1'});
1328
+ });
1329
+
1330
+ it('throws unsupported for non-outdial voice tasks', async () => {
1331
+ const taskData = createBaseData();
1332
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1333
+
1334
+ await expect(voice.decline()).rejects.toThrow('Unsupported operation: decline');
1335
+ expect(dummyContact.cancelTask).not.toHaveBeenCalled();
1336
+ });
1337
+
1338
+ it('throws unsupported when enableWxBetterTogether is false', async () => {
1339
+ const taskData = makeWxAppOutdialTaskData();
1340
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: false});
1341
+
1342
+ await expect(voice.decline()).rejects.toThrow('Unsupported operation: decline');
1343
+ expect(dummyContact.cancelTask).not.toHaveBeenCalled();
1344
+ });
1345
+ });
1346
+
1347
+ describe('unified task API routing (WXCC-6026)', () => {
1348
+ const wxAppParticipant = {
1349
+ deviceType: 'wxApp',
1350
+ deviceId: 'device-id-1',
1351
+ deviceCallId: 'call-id-1',
1352
+ };
1353
+
1354
+ it('accept() routes wxApp inbound offer to telephony answerCall', async () => {
1355
+ const mockSvc = {
1356
+ answerCall: jest.fn().mockResolvedValue(undefined),
1357
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1358
+ };
1359
+ const taskData = createBaseData({
1360
+ agentId: 'agent-1',
1361
+ interaction: {
1362
+ participants: {
1363
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1364
+ },
1365
+ } as any,
1366
+ });
1367
+ const voice = new Voice(dummyContact, taskData, {
1368
+ enableWxBetterTogether: true,
1369
+ answerCallOnWebexService: mockSvc as any,
1370
+ });
1371
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1372
+
1373
+ await voice.accept();
1374
+
1375
+ expect(mockSvc.answerCall).toHaveBeenCalledWith({
1376
+ callId: 'call-id-1',
1377
+ endpointId: 'device-id-1',
1378
+ lineOwnerId: undefined,
1379
+ });
1380
+ });
1381
+
1382
+ it('decline() routes wxApp inbound offer to telephony rejectCall', async () => {
1383
+ const mockSvc = {
1384
+ rejectCall: jest.fn().mockResolvedValue(undefined),
1385
+ };
1386
+ const taskData = createBaseData({
1387
+ agentId: 'agent-1',
1388
+ interaction: {
1389
+ participants: {
1390
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1391
+ },
1392
+ } as any,
1393
+ });
1394
+ const voice = new Voice(dummyContact, taskData, {
1395
+ enableWxBetterTogether: true,
1396
+ answerCallOnWebexService: mockSvc as any,
1397
+ });
1398
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1399
+
1400
+ await voice.decline();
1401
+
1402
+ expect(mockSvc.rejectCall).toHaveBeenCalledWith({
1403
+ callId: 'call-id-1',
1404
+ lineOwnerId: undefined,
1405
+ });
1406
+ expect(dummyContact.cancelTask).not.toHaveBeenCalled();
1407
+ });
1408
+
1409
+ it('toggleMute() routes wxApp engaged call to telephony muteCall', async () => {
1410
+ const mockSvc = {
1411
+ muteCall: jest.fn().mockResolvedValue(undefined),
1412
+ };
1413
+ const taskData = createBaseData({
1414
+ agentId: 'agent-1',
1415
+ interaction: {
1416
+ participants: {
1417
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1418
+ },
1419
+ } as any,
1420
+ });
1421
+ const voice = new Voice(dummyContact, taskData, {
1422
+ enableWxBetterTogether: true,
1423
+ answerCallOnWebexService: mockSvc as any,
1424
+ });
1425
+ primeConnectedState(voice, taskData);
1426
+
1427
+ await voice.toggleMute({muted: true});
1428
+
1429
+ expect(mockSvc.muteCall).toHaveBeenCalledWith({callId: 'call-id-1', lineOwnerId: undefined});
1430
+ });
1431
+
1432
+ it('serializes concurrent no-arg toggleMute() so mute then unmute', async () => {
1433
+ let resolveMute: () => void;
1434
+ const muteGate = new Promise<void>((resolve) => {
1435
+ resolveMute = resolve;
1436
+ });
1437
+ const mockSvc = {
1438
+ muteCall: jest.fn().mockImplementation(() => muteGate),
1439
+ unmuteCall: jest.fn().mockResolvedValue(undefined),
1440
+ };
1441
+ const taskData = createBaseData({
1442
+ agentId: 'agent-1',
1443
+ interaction: {
1444
+ participants: {
1445
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1446
+ },
1447
+ } as any,
1448
+ });
1449
+ const voice = new Voice(dummyContact, taskData, {
1450
+ enableWxBetterTogether: true,
1451
+ answerCallOnWebexService: mockSvc as any,
1452
+ });
1453
+ primeConnectedState(voice, taskData);
1454
+ expect(voice.getWxAppMuted()).toBe(false);
1455
+
1456
+ const firstToggle = voice.toggleMute();
1457
+ const secondToggle = voice.toggleMute();
1458
+
1459
+ resolveMute!();
1460
+ await Promise.all([firstToggle, secondToggle]);
1461
+
1462
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
1463
+ expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
1464
+ expect(voice.getWxAppMuted()).toBe(false);
1465
+ });
1466
+
1467
+ it('continues queued toggleMute after predecessor failure', async () => {
1468
+ const mockSvc = {
1469
+ muteCall: jest.fn().mockRejectedValue(new Error('mute failed')),
1470
+ unmuteCall: jest.fn().mockResolvedValue(undefined),
1471
+ };
1472
+ const taskData = createBaseData({
1473
+ agentId: 'agent-1',
1474
+ interaction: {
1475
+ participants: {
1476
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1477
+ },
1478
+ } as any,
1479
+ });
1480
+ const voice = new Voice(dummyContact, taskData, {
1481
+ enableWxBetterTogether: true,
1482
+ answerCallOnWebexService: mockSvc as any,
1483
+ });
1484
+ primeConnectedState(voice, taskData);
1485
+
1486
+ const firstToggle = voice.toggleMute({muted: true});
1487
+ const secondToggle = voice.toggleMute({muted: false});
1488
+
1489
+ await expect(firstToggle).rejects.toThrow('mute failed');
1490
+ await secondToggle;
1491
+
1492
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
1493
+ expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
1494
+ });
1495
+
1496
+ it('transmitDtmf() routes wxApp engaged call to telephony transmitDtmf', async () => {
1497
+ const mockSvc = {
1498
+ transmitDtmf: jest.fn().mockResolvedValue(undefined),
1499
+ };
1500
+ const taskData = createBaseData({
1501
+ agentId: 'agent-1',
1502
+ interaction: {
1503
+ participants: {
1504
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1505
+ },
1506
+ } as any,
1507
+ });
1508
+ const voice = new Voice(dummyContact, taskData, {
1509
+ enableWxBetterTogether: true,
1510
+ answerCallOnWebexService: mockSvc as any,
1511
+ });
1512
+ primeConnectedState(voice, taskData);
1513
+
1514
+ await voice.transmitDtmf({dtmf: '5'});
1515
+
1516
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
1517
+ callId: 'call-id-1',
1518
+ dtmf: '5',
1519
+ lineOwnerId: undefined,
1520
+ });
1521
+ });
1522
+ });
1523
+ });