From 1cb0589263990c3e943d6e3836c3d4f0c3b365ce Mon Sep 17 00:00:00 2001 From: Hung Phan Viet Date: Wed, 16 Sep 2026 11:37:52 +0700 Subject: [PATCH 01/12] feat(whatsapp): add in-app voice calling --- .env.example | 18 + .gitignore | 3 + .../call-recording-activity.test.tsx | 176 + .../__tests__/chat-layout-mobile.test.tsx | 33 +- apps/builder/__tests__/chat-realtime.test.tsx | 360 + apps/builder/__tests__/chat-store.test.ts | 75 + .../__tests__/conversation-item.test.tsx | 190 + .../__tests__/conversation-list.test.tsx | 91 + .../find-contact-inbox-by-channel.test.ts | 32 + .../generate-call-ai-summary-action.test.ts | 116 + .../get-call-recording-url-action.test.ts | 65 + .../__tests__/get-call-summary-action.test.ts | 65 + .../get-call-transcript-action.test.ts | 68 + ...-pending-incoming-voip-call-action.test.ts | 69 + ...list-call-summary-providers-action.test.ts | 51 + .../realtime-connect-token-api.test.ts | 117 + .../__tests__/reserved-custom-fields.test.ts | 2 + .../__tests__/use-whatsapp-voip-call.test.tsx | 1785 + apps/builder/__tests__/webhook-log.test.ts | 71 + .../whatsapp-answer-voip-call-action.test.ts | 657 + ...hatsapp-blocked-outbound-countries.test.ts | 14 + .../whatsapp-call-ai-summary-dialog.test.tsx | 183 + ...whatsapp-call-announcement-options.test.ts | 206 + ...call-announcement-validation-error.test.ts | 53 + .../__tests__/whatsapp-call-card.test.tsx | 387 + .../whatsapp-call-info-sheet.test.tsx | 276 + .../__tests__/whatsapp-call-panel.test.tsx | 381 + .../__tests__/whatsapp-call-recorder.test.ts | 181 + ...atsapp-call-recording-upload-route.test.ts | 373 + ...-calling-preflight-messaging-limit.test.ts | 148 + .../whatsapp-calling-preflight.test.tsx | 203 + .../whatsapp-calling-settings-action.test.ts | 154 + .../__tests__/whatsapp-calls-card.test.tsx | 155 + .../whatsapp-connect-webhook-url.test.ts | 55 + .../whatsapp-end-voip-call-as-agent.test.ts | 286 + .../whatsapp-hangup-voip-call-action.test.ts | 188 + ...-heartbeat-active-voip-call-action.test.ts | 65 + ...app-heartbeat-voip-presence-action.test.ts | 49 + ...initiate-outbound-voip-call-action.test.ts | 648 + .../whatsapp-manual-webhook-route.test.ts | 186 + ...bound-voip-turn-credentials-action.test.ts | 167 + ...-resolve-outbound-call-mode-action.test.ts | 366 + .../whatsapp-settings-actions.test.ts | 80 +- .../whatsapp-voip-call-button.test.tsx | 664 + .../whatsapp-voip-call-context.test.tsx | 117 + .../whatsapp-voip-call-hangup-route.test.ts | 135 + .../whatsapp-voip-call-store.test.ts | 490 + ...tsapp-voip-turn-credentials-action.test.ts | 165 + .../workspace-members-actions.test.ts | 3 + apps/builder/messages/ar.json | 218 +- apps/builder/messages/da.json | 218 +- apps/builder/messages/de.json | 218 +- apps/builder/messages/en.json | 218 +- apps/builder/messages/es.json | 218 +- apps/builder/messages/fi.json | 218 +- apps/builder/messages/fr.json | 218 +- apps/builder/messages/he.json | 218 +- apps/builder/messages/id.json | 218 +- apps/builder/messages/it.json | 218 +- apps/builder/messages/ja.json | 218 +- apps/builder/messages/nl.json | 218 +- apps/builder/messages/pt-BR.json | 218 +- apps/builder/messages/pt-PT.json | 218 +- apps/builder/messages/ro.json | 218 +- apps/builder/messages/sv.json | 218 +- apps/builder/messages/tr.json | 218 +- apps/builder/messages/vi.json | 218 +- apps/builder/messages/zh-CN.json | 218 +- apps/builder/messages/zh-TW.json | 218 +- .../app/api/whatsapp-call-recording/route.ts | 208 + .../api/whatsapp-voip-call-hangup/route.ts | 70 + .../integrations/[...integration]/webhook.ts | 8 +- .../whatsapp/webhook/[integrationId]/route.ts | 44 +- .../whatsapps/[id]/calls/page.tsx | 65 + apps/builder/src/env.ts | 3 + .../builder/src/features/chat/chat-layout.tsx | 198 +- .../src/features/chat/chat-realtime.tsx | 125 +- .../src/features/chat/store/chat-store.ts | 81 + .../conversations/conversation-item.tsx | 54 +- .../conversations/conversation-list.tsx | 1 + .../conversations/utils/contact-inbox.ts | 10 + .../provider/custom-field-hook.ts | 10 + .../react-flow/nodes/send-message/menu.tsx | 20 +- .../features/flows/react-flow/steps/index.tsx | 2 + .../steps/whatsapp-call-button/editor.tsx | 157 + .../steps/whatsapp-call-button/index.ts | 17 + .../steps/whatsapp-call-button/viewer.tsx | 29 + .../actions/webhook-url.ts | 17 +- .../actions/answer-voip-call.action.ts | 362 + .../actions/blocked-outbound-countries.ts | 19 + .../actions/call-announcement-options.ts | 132 + .../calling/actions/end-voip-call-as-agent.ts | 157 + .../fix-whatsapp-calls-subscription.action.ts | 77 + .../get-pending-incoming-voip-call.action.ts | 23 + .../actions/hangup-voip-call.action.ts | 31 + .../heartbeat-active-voip-call.action.ts | 43 + .../actions/heartbeat-voip-presence.action.ts | 26 + .../initiate-outbound-voip-call.action.ts | 476 + .../calling/actions/outbound-dial-target.ts | 104 + .../outbound-voip-turn-credentials.action.ts | 116 + .../actions/request-call-permission.action.ts | 93 + .../resolve-outbound-call-mode.action.ts | 220 + .../actions/update-calling-settings.action.ts | 103 + .../actions/voip-turn-credentials.action.ts | 61 + .../calling/get-whatsapp-calling-preflight.ts | 177 + .../request-call-permission-dialog.tsx | 112 + .../schemas/update-calling-settings-schema.ts | 21 + .../calling/voip/call-recorder.ts | 205 + .../voip/outbound-call-mode-query-key.ts | 8 + .../calling/voip/use-outbound-call-mode.ts | 40 + .../calling/voip/use-voip-ringback.ts | 85 + .../calling/voip/use-voip-ringtone.ts | 73 + .../calling/voip/use-whatsapp-voip-call.ts | 1126 + .../voip/use-whatsapp-voip-presence.ts | 47 + .../calling/voip/voip-call-store.ts | 409 + .../calling/voip/voip-peer-connection.ts | 209 + .../calling/voip/whatsapp-call-panel.tsx | 398 + .../voip/whatsapp-voip-call-button.tsx | 371 + .../voip/whatsapp-voip-call-context.tsx | 82 + .../calling/whatsapp-calls-card.tsx | 362 + .../components/whatsapp-setting-tabs.tsx | 4 + .../libs/ensure-calls-webhook-subscribed.ts | 31 + .../libs/whatsapp-api-action-error.ts | 38 + .../verification/actions.ts | 29 +- .../generate-call-ai-summary.action.ts | 58 + .../actions/get-call-recording-url.action.ts | 37 + .../actions/get-call-summary.action.ts | 29 + .../actions/get-call-transcript.action.ts | 31 + .../list-call-summary-providers.action.ts | 23 + .../messages/components/call-audio-player.tsx | 126 + .../components/call-recording-activity.tsx | 123 + .../messages/components/input-menu.tsx | 41 +- .../messages/components/message-item.tsx | 150 +- .../whatsapp-call-ai-summary-dialog.tsx | 219 + .../components/whatsapp-call-card.tsx | 372 + .../components/whatsapp-call-info-sheet.tsx | 594 + .../src/features/messages/message-head.tsx | 29 + .../messages/store/call-info-sheet-store.ts | 49 + .../messages/store/call-playback-store.ts | 169 + .../update-whatsapp-settings.action.ts | 39 + .../whatsapp/whatsapp-settings.tsx | 5 +- .../src/features/realtime/api/index.ts | 5 + .../src/features/realtime/api/private.ts | 54 + .../src/features/triggers/add-condition.tsx | 56 +- .../src/features/triggers/base-editor.tsx | 4 + .../src/features/webhooks/add-condition.tsx | 56 +- .../actions/delete-workspace-member.action.ts | 12 + .../builder/src/lib/http/same-site-request.ts | 16 + apps/builder/src/routers/index.ts | 5 + .../__tests__/workspaces-party.test.ts | 259 + apps/realtime/package.json | 2 +- apps/realtime/src/lib/auth.ts | 2 - apps/realtime/src/lib/connection-tags.ts | 10 + apps/realtime/src/parties/workspaces.ts | 111 +- .../__tests__/integration-worker-boot.test.ts | 60 +- ...ntegration-worker-incoming-message.test.ts | 82 +- .../__tests__/send-message-handler.test.ts | 70 + .../sweep-stale-whatsapp-calls.test.ts | 144 + .../__tests__/whatsapp-call-finalize.test.ts | 815 + .../__tests__/whatsapp-call-handler.test.ts | 919 + .../whatsapp-call-native-media.test.ts | 207 + .../whatsapp-call-native-recording.test.ts | 363 + .../whatsapp-call-native-transcript.test.ts | 350 + .../whatsapp-call-permission-grant.test.ts | 125 + .../__tests__/whatsapp-call-recording.test.ts | 339 + .../whatsapp-call-transcribe.test.ts | 271 + .../__tests__/whatsapp-voip-signaling.test.ts | 743 + .../channel-send-error-reconcilers.ts | 36 + .../src/chat/handlers/send-flow-step.ts | 44 +- apps/worker/src/chat/handlers/send-message.ts | 28 +- .../whatsapp-call-permission-grant.ts | 62 + .../src/chat/utils/broadcast-chat-event.ts | 13 + apps/worker/src/env.ts | 9 + .../handlers/coexist/attachment-download.ts | 24 +- .../src/integration/handlers/flow-utils.ts | 1 + .../handlers/shared/whatsapp-call-finalize.ts | 510 + .../shared/whatsapp-call-native-media.ts | 227 + .../whatsapp-call-recording-enrichment.ts | 28 + apps/worker/src/integration/handlers/step.ts | 1 + .../whatsapp-call-native-recording.ts | 170 + .../whatsapp-call-native-transcript.ts | 247 + .../handlers/whatsapp-call-recording.ts | 202 + .../handlers/whatsapp-call-transcribe.ts | 164 + .../src/integration/handlers/whatsapp-call.ts | 585 + .../handlers/whatsapp-voip-signaling.ts | 523 + apps/worker/src/integration/worker.ts | 146 +- .../handlers/purge-expired-call-recordings.ts | 27 + .../schedule/handlers/register-schedules.ts | 29 + .../handlers/sweep-stale-whatsapp-calls.ts | 97 + apps/worker/src/schedule/worker.ts | 10 + .../services/webhook-payload.builder.ts | 27 + docker-compose.yml | 30 + docker/coturn/turnserver.conf | 40 + docs/whatsapp-calling-voip.md | 318 + docs/whatsapp-calling.md | 47 + .../__tests__/app-subscriptions.test.ts | 232 + .../automatic-events-webhook.test.ts | 29 +- .../__tests__/call-permission-reply.test.ts | 79 + .../__tests__/call-permissions.test.ts | 82 + .../__tests__/calling-recording.test.ts | 93 + .../__tests__/calling-voip-actions.test.ts | 468 + .../whatsapp/__tests__/calls-webhook.test.ts | 1687 + .../__tests__/coexist-webhook.test.ts | 301 +- .../incoming-message-parsers.test.ts | 34 +- .../__tests__/outgoing-call-button.test.ts | 64 + .../outgoing-call-permission-request.test.ts | 109 + .../__tests__/signature-policy.test.ts | 54 + .../__tests__/subscribe-webhook.test.ts | 24 + .../whatsapp/__tests__/webhook-hmac.test.ts | 347 + .../whatsapp/src/api/app-subscriptions.ts | 236 + integrations/whatsapp/src/api/calling.ts | 539 + integrations/whatsapp/src/api/phone-number.ts | 13 + .../src/handlers/message/incoming-reply.ts | 63 + .../message/outgoing-message/index.ts | 38 +- .../outgoing-message/whatsapp-call-button.ts | 50 + integrations/whatsapp/src/handlers/webhook.ts | 889 +- integrations/whatsapp/src/integration.ts | 4 + .../whatsapp/src/lib/calling-recording.ts | 119 + integrations/whatsapp/src/lib/calls.ts | 628 + .../whatsapp/src/lib/signature-policy.ts | 42 + integrations/whatsapp/src/schema.ts | 71 + packages/ai/src/server/index.ts | 1 + .../ai/src/server/services/call-summarizer.ts | 120 + .../whatsapp-call-lifecycle-service.test.ts | 88 + .../whatsapp-call-permission-service.test.ts | 144 + .../whatsapp-call-recording-service.test.ts | 233 + .../whatsapp-call-summary-service.test.ts | 345 + .../whatsapp-call-voip-call-service.test.ts | 1886 + .../whatsapp-voip-presence-service.test.ts | 73 + packages/business/src/errors.ts | 14 + packages/business/src/index.ts | 1 + .../src/integration-whatsapp/service.ts | 25 + .../business/src/message/create-outgoing.ts | 12 +- .../src/platform/realtime-broadcast.ts | 32 + .../whatsapp-call/call-lifecycle-service.ts | 46 + .../whatsapp-call/call-permission-service.ts | 111 + .../whatsapp-call/call-recording-service.ts | 163 + .../src/whatsapp-call/call-summary-service.ts | 309 + packages/business/src/whatsapp-call/index.ts | 10 + .../src/whatsapp-call/turn-credential.ts | 44 + .../src/whatsapp-call/voip-call-control.ts | 263 + .../src/whatsapp-call/voip-call-service.ts | 830 + .../whatsapp-call/voip-presence-service.ts | 63 + .../whatsapp-call/voip-signaling-service.ts | 464 + .../voip-turn-credential-service.ts | 80 + .../sharded-message-repository.test.ts | 62 + .../whatsapp-call-repository.test.ts | 959 + .../snapshot.json | 2 +- .../migration.sql | 142 + .../snapshot.json | 45219 +++++++++++++++ .../migration.sql | 2 + .../snapshot.json | 45689 ++++++++++++++++ packages/database/package.json | 1 + .../database/scripts/reconcile-ledger.mjs | 288 + packages/database/src/partials/contact.ts | 2 + packages/database/src/partials/index.ts | 1 + .../src/partials/integration-whatsapp.ts | 24 + packages/database/src/partials/trigger.ts | 2 + .../database/src/partials/whatsapp-call.ts | 74 + packages/database/src/relations/index.ts | 4 + .../src/relations/whatsapp-call-permission.ts | 21 + .../database/src/relations/whatsapp-call.ts | 33 + packages/database/src/repositories/index.ts | 1 + .../integration-whatsapp/repository.ts | 50 + .../message/message-repository.ts | 29 + .../src/repositories/whatsapp-call/index.ts | 2 + .../whatsapp-call/permission-repository.ts | 59 + .../repositories/whatsapp-call/repository.ts | 984 + packages/database/src/schema/index.ts | 2 + .../src/schema/integration-whatsapp.ts | 46 + .../src/schema/whatsapp-call-permission.ts | 66 + packages/database/src/schema/whatsapp-call.ts | 216 + .../repository/sharded-message-repository.ts | 87 +- packages/database/src/types.ts | 3 + packages/events/src/base-emitter.ts | 78 + packages/events/src/event-dispatcher.ts | 41 +- packages/events/src/event-type-registry.ts | 5 + packages/flow-config/src/index.ts | 1 + .../flow-config/src/nodes/send-message.ts | 2 + packages/flow-config/src/steps/step-action.ts | 1 + .../src/steps/whatsapp-call-button.ts | 40 + packages/logger/__tests__/redact.test.ts | 44 + packages/logger/src/redact.ts | 1 + .../partysocket-config/__tests__/auth.test.ts | 128 + .../partysocket-config/__tests__/lib.test.ts | 137 + .../__tests__/schemas.test.ts | 194 + packages/partysocket-config/src/auth.ts | 69 +- packages/partysocket-config/src/lib.ts | 103 +- packages/partysocket-config/src/schemas.ts | 197 + packages/redis/__tests__/cas-store.test.ts | 183 + .../redis/__tests__/presence-store.test.ts | 72 + packages/redis/src/cas-store.ts | 137 + packages/redis/src/index.ts | 15 + packages/redis/src/presence-store.ts | 46 + packages/sdk/src/lib/flow-step-data.ts | 2 + packages/sdk/src/lib/shared/index.ts | 1 + packages/sdk/src/lib/shared/message.ts | 213 + .../src/lib/shared/recording-content-type.ts | 22 + packages/utils/__tests__/crypto.test.ts | 110 +- packages/utils/src/crypto.ts | 59 + .../variables/__tests__/last-call.test.ts | 107 + .../system-fields-exhaustive.test.ts | 7 + packages/variables/src/helpers/last-call.ts | 46 + packages/variables/src/utils.ts | 8 + .../__tests__/job-wait-strict.test.ts | 40 + ...whatsapp-call-native-media-job-ids.test.ts | 122 + .../whatsapp-voip-signaling-job-ids.test.ts | 101 + packages/worker-config/src/index.ts | 2 + packages/worker-config/src/lib/job-wait.ts | 45 + packages/worker-config/src/lib/types.ts | 2 + .../src/queues/call-transcription/index.ts | 45 + .../worker-config/src/queues/chat/index.ts | 2 + .../src/queues/integration/index.ts | 229 +- .../src/queues/schedule/index.ts | 17 + .../queues/whatsapp-voip-signaling/index.ts | 159 + 315 files changed, 140568 insertions(+), 577 deletions(-) create mode 100644 apps/builder/__tests__/call-recording-activity.test.tsx create mode 100644 apps/builder/__tests__/chat-realtime.test.tsx create mode 100644 apps/builder/__tests__/conversation-item.test.tsx create mode 100644 apps/builder/__tests__/conversation-list.test.tsx create mode 100644 apps/builder/__tests__/find-contact-inbox-by-channel.test.ts create mode 100644 apps/builder/__tests__/generate-call-ai-summary-action.test.ts create mode 100644 apps/builder/__tests__/get-call-recording-url-action.test.ts create mode 100644 apps/builder/__tests__/get-call-summary-action.test.ts create mode 100644 apps/builder/__tests__/get-call-transcript-action.test.ts create mode 100644 apps/builder/__tests__/get-pending-incoming-voip-call-action.test.ts create mode 100644 apps/builder/__tests__/list-call-summary-providers-action.test.ts create mode 100644 apps/builder/__tests__/realtime-connect-token-api.test.ts create mode 100644 apps/builder/__tests__/use-whatsapp-voip-call.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-answer-voip-call-action.test.ts create mode 100644 apps/builder/__tests__/whatsapp-blocked-outbound-countries.test.ts create mode 100644 apps/builder/__tests__/whatsapp-call-ai-summary-dialog.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-call-announcement-options.test.ts create mode 100644 apps/builder/__tests__/whatsapp-call-announcement-validation-error.test.ts create mode 100644 apps/builder/__tests__/whatsapp-call-card.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-call-info-sheet.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-call-panel.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-call-recorder.test.ts create mode 100644 apps/builder/__tests__/whatsapp-call-recording-upload-route.test.ts create mode 100644 apps/builder/__tests__/whatsapp-calling-preflight-messaging-limit.test.ts create mode 100644 apps/builder/__tests__/whatsapp-calling-preflight.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-calling-settings-action.test.ts create mode 100644 apps/builder/__tests__/whatsapp-calls-card.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-end-voip-call-as-agent.test.ts create mode 100644 apps/builder/__tests__/whatsapp-hangup-voip-call-action.test.ts create mode 100644 apps/builder/__tests__/whatsapp-heartbeat-active-voip-call-action.test.ts create mode 100644 apps/builder/__tests__/whatsapp-heartbeat-voip-presence-action.test.ts create mode 100644 apps/builder/__tests__/whatsapp-initiate-outbound-voip-call-action.test.ts create mode 100644 apps/builder/__tests__/whatsapp-manual-webhook-route.test.ts create mode 100644 apps/builder/__tests__/whatsapp-outbound-voip-turn-credentials-action.test.ts create mode 100644 apps/builder/__tests__/whatsapp-resolve-outbound-call-mode-action.test.ts create mode 100644 apps/builder/__tests__/whatsapp-voip-call-button.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-voip-call-context.test.tsx create mode 100644 apps/builder/__tests__/whatsapp-voip-call-hangup-route.test.ts create mode 100644 apps/builder/__tests__/whatsapp-voip-call-store.test.ts create mode 100644 apps/builder/__tests__/whatsapp-voip-turn-credentials-action.test.ts create mode 100644 apps/builder/src/app/api/whatsapp-call-recording/route.ts create mode 100644 apps/builder/src/app/api/whatsapp-voip-call-hangup/route.ts create mode 100644 apps/builder/src/app/space/[workspaceId]/(integrations)/whatsapps/[id]/calls/page.tsx create mode 100644 apps/builder/src/features/conversations/utils/contact-inbox.ts create mode 100644 apps/builder/src/features/flows/react-flow/steps/whatsapp-call-button/editor.tsx create mode 100644 apps/builder/src/features/flows/react-flow/steps/whatsapp-call-button/index.ts create mode 100644 apps/builder/src/features/flows/react-flow/steps/whatsapp-call-button/viewer.tsx create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/answer-voip-call.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/blocked-outbound-countries.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/call-announcement-options.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/end-voip-call-as-agent.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/fix-whatsapp-calls-subscription.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/get-pending-incoming-voip-call.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/hangup-voip-call.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/heartbeat-active-voip-call.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/heartbeat-voip-presence.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/initiate-outbound-voip-call.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/outbound-dial-target.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/outbound-voip-turn-credentials.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/request-call-permission.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/resolve-outbound-call-mode.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/update-calling-settings.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/actions/voip-turn-credentials.action.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/get-whatsapp-calling-preflight.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/request-call-permission-dialog.tsx create mode 100644 apps/builder/src/features/integration-whatsapp/calling/schemas/update-calling-settings-schema.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/call-recorder.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/outbound-call-mode-query-key.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/use-outbound-call-mode.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/use-voip-ringback.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/use-voip-ringtone.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/use-whatsapp-voip-call.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/use-whatsapp-voip-presence.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/voip-call-store.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/voip-peer-connection.ts create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/whatsapp-call-panel.tsx create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/whatsapp-voip-call-button.tsx create mode 100644 apps/builder/src/features/integration-whatsapp/calling/voip/whatsapp-voip-call-context.tsx create mode 100644 apps/builder/src/features/integration-whatsapp/calling/whatsapp-calls-card.tsx create mode 100644 apps/builder/src/features/integration-whatsapp/libs/ensure-calls-webhook-subscribed.ts create mode 100644 apps/builder/src/features/integration-whatsapp/libs/whatsapp-api-action-error.ts create mode 100644 apps/builder/src/features/messages/actions/generate-call-ai-summary.action.ts create mode 100644 apps/builder/src/features/messages/actions/get-call-recording-url.action.ts create mode 100644 apps/builder/src/features/messages/actions/get-call-summary.action.ts create mode 100644 apps/builder/src/features/messages/actions/get-call-transcript.action.ts create mode 100644 apps/builder/src/features/messages/actions/list-call-summary-providers.action.ts create mode 100644 apps/builder/src/features/messages/components/call-audio-player.tsx create mode 100644 apps/builder/src/features/messages/components/call-recording-activity.tsx create mode 100644 apps/builder/src/features/messages/components/whatsapp-call-ai-summary-dialog.tsx create mode 100644 apps/builder/src/features/messages/components/whatsapp-call-card.tsx create mode 100644 apps/builder/src/features/messages/components/whatsapp-call-info-sheet.tsx create mode 100644 apps/builder/src/features/messages/store/call-info-sheet-store.ts create mode 100644 apps/builder/src/features/messages/store/call-playback-store.ts create mode 100644 apps/builder/src/features/realtime/api/index.ts create mode 100644 apps/builder/src/features/realtime/api/private.ts create mode 100644 apps/builder/src/lib/http/same-site-request.ts create mode 100644 apps/realtime/__tests__/workspaces-party.test.ts create mode 100644 apps/realtime/src/lib/connection-tags.ts create mode 100644 apps/worker/__tests__/sweep-stale-whatsapp-calls.test.ts create mode 100644 apps/worker/__tests__/whatsapp-call-finalize.test.ts create mode 100644 apps/worker/__tests__/whatsapp-call-handler.test.ts create mode 100644 apps/worker/__tests__/whatsapp-call-native-media.test.ts create mode 100644 apps/worker/__tests__/whatsapp-call-native-recording.test.ts create mode 100644 apps/worker/__tests__/whatsapp-call-native-transcript.test.ts create mode 100644 apps/worker/__tests__/whatsapp-call-permission-grant.test.ts create mode 100644 apps/worker/__tests__/whatsapp-call-recording.test.ts create mode 100644 apps/worker/__tests__/whatsapp-call-transcribe.test.ts create mode 100644 apps/worker/__tests__/whatsapp-voip-signaling.test.ts create mode 100644 apps/worker/src/chat/handlers/channel-send-error-reconcilers.ts create mode 100644 apps/worker/src/chat/handlers/whatsapp-call-permission-grant.ts create mode 100644 apps/worker/src/chat/utils/broadcast-chat-event.ts create mode 100644 apps/worker/src/integration/handlers/shared/whatsapp-call-finalize.ts create mode 100644 apps/worker/src/integration/handlers/shared/whatsapp-call-native-media.ts create mode 100644 apps/worker/src/integration/handlers/shared/whatsapp-call-recording-enrichment.ts create mode 100644 apps/worker/src/integration/handlers/whatsapp-call-native-recording.ts create mode 100644 apps/worker/src/integration/handlers/whatsapp-call-native-transcript.ts create mode 100644 apps/worker/src/integration/handlers/whatsapp-call-recording.ts create mode 100644 apps/worker/src/integration/handlers/whatsapp-call-transcribe.ts create mode 100644 apps/worker/src/integration/handlers/whatsapp-call.ts create mode 100644 apps/worker/src/integration/handlers/whatsapp-voip-signaling.ts create mode 100644 apps/worker/src/schedule/handlers/purge-expired-call-recordings.ts create mode 100644 apps/worker/src/schedule/handlers/sweep-stale-whatsapp-calls.ts create mode 100644 docker/coturn/turnserver.conf create mode 100644 docs/whatsapp-calling-voip.md create mode 100644 docs/whatsapp-calling.md create mode 100644 integrations/whatsapp/__tests__/app-subscriptions.test.ts create mode 100644 integrations/whatsapp/__tests__/call-permission-reply.test.ts create mode 100644 integrations/whatsapp/__tests__/call-permissions.test.ts create mode 100644 integrations/whatsapp/__tests__/calling-recording.test.ts create mode 100644 integrations/whatsapp/__tests__/calling-voip-actions.test.ts create mode 100644 integrations/whatsapp/__tests__/calls-webhook.test.ts create mode 100644 integrations/whatsapp/__tests__/outgoing-call-button.test.ts create mode 100644 integrations/whatsapp/__tests__/outgoing-call-permission-request.test.ts create mode 100644 integrations/whatsapp/__tests__/signature-policy.test.ts create mode 100644 integrations/whatsapp/__tests__/webhook-hmac.test.ts create mode 100644 integrations/whatsapp/src/api/app-subscriptions.ts create mode 100644 integrations/whatsapp/src/api/calling.ts create mode 100644 integrations/whatsapp/src/handlers/message/outgoing-message/whatsapp-call-button.ts create mode 100644 integrations/whatsapp/src/lib/calling-recording.ts create mode 100644 integrations/whatsapp/src/lib/calls.ts create mode 100644 integrations/whatsapp/src/lib/signature-policy.ts create mode 100644 packages/ai/src/server/services/call-summarizer.ts create mode 100644 packages/business/__tests__/whatsapp-call-lifecycle-service.test.ts create mode 100644 packages/business/__tests__/whatsapp-call-permission-service.test.ts create mode 100644 packages/business/__tests__/whatsapp-call-recording-service.test.ts create mode 100644 packages/business/__tests__/whatsapp-call-summary-service.test.ts create mode 100644 packages/business/__tests__/whatsapp-call-voip-call-service.test.ts create mode 100644 packages/business/__tests__/whatsapp-voip-presence-service.test.ts create mode 100644 packages/business/src/whatsapp-call/call-lifecycle-service.ts create mode 100644 packages/business/src/whatsapp-call/call-permission-service.ts create mode 100644 packages/business/src/whatsapp-call/call-recording-service.ts create mode 100644 packages/business/src/whatsapp-call/call-summary-service.ts create mode 100644 packages/business/src/whatsapp-call/index.ts create mode 100644 packages/business/src/whatsapp-call/turn-credential.ts create mode 100644 packages/business/src/whatsapp-call/voip-call-control.ts create mode 100644 packages/business/src/whatsapp-call/voip-call-service.ts create mode 100644 packages/business/src/whatsapp-call/voip-presence-service.ts create mode 100644 packages/business/src/whatsapp-call/voip-signaling-service.ts create mode 100644 packages/business/src/whatsapp-call/voip-turn-credential-service.ts create mode 100644 packages/database/__tests__/whatsapp-call-repository.test.ts create mode 100644 packages/database/drizzle/20260914150000_whatsapp_calling_consolidated/migration.sql create mode 100644 packages/database/drizzle/20260914150000_whatsapp_calling_consolidated/snapshot.json create mode 100644 packages/database/drizzle/20260915200459_whatsapp_call_query_indexes/migration.sql create mode 100644 packages/database/drizzle/20260915200459_whatsapp_call_query_indexes/snapshot.json create mode 100644 packages/database/scripts/reconcile-ledger.mjs create mode 100644 packages/database/src/partials/whatsapp-call.ts create mode 100644 packages/database/src/relations/whatsapp-call-permission.ts create mode 100644 packages/database/src/relations/whatsapp-call.ts create mode 100644 packages/database/src/repositories/whatsapp-call/index.ts create mode 100644 packages/database/src/repositories/whatsapp-call/permission-repository.ts create mode 100644 packages/database/src/repositories/whatsapp-call/repository.ts create mode 100644 packages/database/src/schema/whatsapp-call-permission.ts create mode 100644 packages/database/src/schema/whatsapp-call.ts create mode 100644 packages/flow-config/src/steps/whatsapp-call-button.ts create mode 100644 packages/partysocket-config/__tests__/auth.test.ts create mode 100644 packages/partysocket-config/__tests__/lib.test.ts create mode 100644 packages/partysocket-config/__tests__/schemas.test.ts create mode 100644 packages/redis/__tests__/cas-store.test.ts create mode 100644 packages/redis/__tests__/presence-store.test.ts create mode 100644 packages/redis/src/cas-store.ts create mode 100644 packages/redis/src/presence-store.ts create mode 100644 packages/sdk/src/lib/shared/recording-content-type.ts create mode 100644 packages/variables/__tests__/last-call.test.ts create mode 100644 packages/variables/src/helpers/last-call.ts create mode 100644 packages/worker-config/__tests__/job-wait-strict.test.ts create mode 100644 packages/worker-config/__tests__/whatsapp-call-native-media-job-ids.test.ts create mode 100644 packages/worker-config/__tests__/whatsapp-voip-signaling-job-ids.test.ts create mode 100644 packages/worker-config/src/queues/call-transcription/index.ts create mode 100644 packages/worker-config/src/queues/whatsapp-voip-signaling/index.ts diff --git a/.env.example b/.env.example index 1af1a7ecd6..0190165bd0 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,24 @@ S3_REGION=us-west-2 # Leave unset to use AWS S3. S3_ENDPOINT=http://localhost:9000 +# ───────────────────────────────────────────── +# In-app WhatsApp calling — VoIP (WebRTC) + TURN +# ───────────────────────────────────────────── +# Calling runs over VoIP (browser WebRTC ↔ Meta) with Meta-native call +# recording/transcription. See docs/whatsapp-calling-voip.md. + +# Per-workspace-minute limiter for opt-in call transcription (BullMQ limiter). +CALL_TRANSCRIBE_PER_MIN=10 + +# TURN (coturn) — production only; local dev uses host/STUN ICE candidates. +# TURN_STATIC_SECRET matches coturn's `static-auth-secret` +# (docker/coturn/turnserver.conf); minted into short-lived per-call ICE +# credentials by voipTurnCredentialService, never handed out directly. +# TURN_STATIC_SECRET= +# TURN_URL=turn:turn.example.com:3478 +# TURN_REALM=chatbotx.local +# TURN_CERTS_DIR=./.data/certs + # ───────────────────────────────────────────── # SMTP / Email # ───────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index dc7d0699a0..fe6f1a59ff 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ CLAUDE.md .pnpm-store/ .plans/ + +# Local runtime data bind mounts, e.g. TURN certificates (docker-compose.yml) +.data/ diff --git a/apps/builder/__tests__/call-recording-activity.test.tsx b/apps/builder/__tests__/call-recording-activity.test.tsx new file mode 100644 index 0000000000..03c3b66e71 --- /dev/null +++ b/apps/builder/__tests__/call-recording-activity.test.tsx @@ -0,0 +1,176 @@ +import type { MessageWhatsappCallRecordingEntity } from "@chatbotx.io/sdk" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import type { AttachmentResource } from "@/features/attachments/schema/resource" + +/** Echoes the key back so assertions never depend on the English copy. */ +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("@/hooks/routing", () => ({ + useWorkspaceId: () => "ws-1", +})) + +vi.mock("@/features/attachments/utils", () => ({ + useAttachmentUrl: (attachment: AttachmentResource | undefined) => + attachment?.url ?? undefined, +})) + +const { getCallRecordingUrlActionMock } = vi.hoisted(() => ({ + getCallRecordingUrlActionMock: vi.fn(), +})) + +vi.mock("@/features/messages/actions/get-call-recording-url.action", () => ({ + getCallRecordingUrlAction: getCallRecordingUrlActionMock, +})) + +const { CallRecordingActivity } = await import( + "@/features/messages/components/call-recording-activity" +) + +let container: HTMLDivElement | null = null +let root: Root | null = null + +function renderComponent(ui: React.ReactElement) { + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render(ui) + }) + return container +} + +afterEach(() => { + if (root) { + act(() => { + root?.unmount() + }) + } + container?.remove() + container = null + root = null +}) + +const attachment = { + id: "att-1", + fileType: "audio", + mimeType: "audio/ogg", + url: "https://signed.example/initial", + originPath: "space/ws-1/calls/call-1.ogg", + name: null, +} as unknown as AttachmentResource + +const recording: MessageWhatsappCallRecordingEntity = { + type: "whatsapp_call_recording", + callId: "call-1", +} + +describe("CallRecordingActivity", () => { + beforeEach(() => { + vi.clearAllMocks() + getCallRecordingUrlActionMock.mockResolvedValue({ + data: { url: "https://signed.example/refreshed" }, + }) + }) + + test("renders nothing when there is no attachment", () => { + const el = renderComponent( + , + ) + expect(el.querySelector("audio")).toBeNull() + }) + + test("renders the audio player with the initial attachment URL", () => { + const el = renderComponent( + , + ) + const audio = el.querySelector("audio") + expect(audio).not.toBeNull() + expect(audio?.getAttribute("src")).toBe("https://signed.example/initial") + }) + + test("hides the transcript block when no transcript is present", () => { + const el = renderComponent( + , + ) + expect(el.textContent).not.toContain("showTranscript") + }) + + test("shows a collapsible transcript trigger when a transcript is present", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("recordingActivity.showTranscript") + expect(el.textContent).not.toContain("hello from the call") + }) + + test("expands the transcript text on trigger click", () => { + const el = renderComponent( + , + ) + const trigger = el.querySelector('[data-slot="collapsible-trigger"]') + expect(trigger).not.toBeNull() + act(() => { + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect(el.textContent).toContain("hello from the call") + expect(el.textContent).toContain("recordingActivity.hideTranscript") + }) + + test("play does NOT trigger a URL refetch — swapping src mid-playback would abort it", async () => { + const el = renderComponent( + , + ) + const audio = el.querySelector("audio") as HTMLAudioElement + + await act(async () => { + audio.dispatchEvent(new Event("play")) + await Promise.resolve() + }) + + expect(getCallRecordingUrlActionMock).not.toHaveBeenCalled() + expect(audio.getAttribute("src")).toBe("https://signed.example/initial") + }) + + test("error triggers exactly one refetch that updates src", async () => { + const el = renderComponent( + , + ) + const audio = el.querySelector("audio") as HTMLAudioElement + + await act(async () => { + audio.dispatchEvent(new Event("error")) + await Promise.resolve() + }) + + expect(getCallRecordingUrlActionMock).toHaveBeenCalledTimes(1) + expect(getCallRecordingUrlActionMock).toHaveBeenCalledWith("ws-1", { + whatsappCallId: "call-1", + }) + expect(audio.getAttribute("src")).toBe("https://signed.example/refreshed") + }) + + test("a failed refresh does not throw and leaves the player usable", async () => { + getCallRecordingUrlActionMock.mockRejectedValueOnce(new Error("boom")) + const el = renderComponent( + , + ) + const audio = el.querySelector("audio") as HTMLAudioElement + + await act(async () => { + audio.dispatchEvent(new Event("error")) + await Promise.resolve() + }) + + expect(el.querySelector("audio")).not.toBeNull() + }) +}) diff --git a/apps/builder/__tests__/chat-layout-mobile.test.tsx b/apps/builder/__tests__/chat-layout-mobile.test.tsx index 8d559ddb16..bd4b165a3c 100644 --- a/apps/builder/__tests__/chat-layout-mobile.test.tsx +++ b/apps/builder/__tests__/chat-layout-mobile.test.tsx @@ -1,5 +1,5 @@ import { setViewportWidth } from "@chatbotx.io/vitest-config/setup-dom" -import { act } from "react" +import { act, type ReactNode } from "react" import { createRoot, type Root } from "react-dom/client" import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" @@ -11,6 +11,37 @@ vi.mock("@/features/chat/chat-realtime", () => ({ ChatRealtime: () =>
, })) +vi.mock( + "@/features/integration-whatsapp/calling/voip/whatsapp-call-panel", + () => ({ + WhatsappCallPanel: () =>
, + }), +) + +vi.mock( + "@/features/integration-whatsapp/calling/voip/whatsapp-voip-call-context", + () => ({ + WhatsappVoipCallProvider: ({ children }: { children: ReactNode }) => + children, + useWhatsappVoipCallContext: () => ({ + answer: vi.fn(), + dismiss: vi.fn(), + hangup: vi.fn(), + toggleMute: vi.fn(), + dismissEnded: vi.fn(), + }), + }), +) + +vi.mock( + "@/features/integration-whatsapp/calling/voip/use-whatsapp-voip-presence", + () => ({ useWhatsappVoipPresence: () => undefined }), +) + +vi.mock("@/features/messages/components/whatsapp-call-info-sheet", () => ({ + WhatsappCallInfoSheet: () =>
, +})) + const mockRouterReplace = vi.fn() vi.mock("next/navigation", () => ({ diff --git a/apps/builder/__tests__/chat-realtime.test.tsx b/apps/builder/__tests__/chat-realtime.test.tsx new file mode 100644 index 0000000000..c710b2f23c --- /dev/null +++ b/apps/builder/__tests__/chat-realtime.test.tsx @@ -0,0 +1,360 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { useWhatsappVoipCallStore } from "@/features/integration-whatsapp/calling/voip/voip-call-store" + +vi.mock("@/hooks/routing", () => ({ + useWorkspaceId: () => "workspace-1", +})) + +const { invalidateQueriesMock } = vi.hoisted(() => ({ + invalidateQueriesMock: vi.fn(), +})) +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }), +})) + +vi.mock("@/features/tenant", () => ({ + useTenantSettings: () => ({ wsUrl: "ws://localhost:1999" }), +})) + +const { authSessionMock } = vi.hoisted(() => ({ + authSessionMock: vi.fn(() => ({ data: { user: { id: "user-winner" } } })), +})) +vi.mock("@/lib/auth/auth-client", () => ({ + authClient: { useSession: authSessionMock }, +})) + +vi.mock("@/lib/orpc/orpc", () => ({ + client: { + realtimeAPI: { + mintWorkspaceConnectTokenAuthenticatedAPI: vi + .fn() + .mockResolvedValue({ token: "token-1" }), + }, + }, +})) + +const bubbleConversationToTopMock = vi.fn().mockResolvedValue(undefined) +const chatStoreState = { + handleNewMessage: vi.fn(), + markMessagesDeleted: vi.fn(), + markMessageFailed: vi.fn(), + assignMessageCommentId: vi.fn(), + updateMessageText: vi.fn(), + updateMessageContentAttributes: vi.fn(), + updateContact: vi.fn(), + updateConversations: vi.fn(), + bubbleConversationToTop: bubbleConversationToTopMock, +} +vi.mock("@/features/chat/store/chat-store-provider", () => ({ + useChatStore: (selector: (state: typeof chatStoreState) => unknown) => + selector(chatStoreState), +})) + +let capturedOnMessage: ((event: { data: string }) => void) | null = null +vi.mock("partysocket/react", () => ({ + default: (options: { onMessage: (event: { data: string }) => void }) => { + capturedOnMessage = options.onMessage + return {} + }, +})) + +const { ChatRealtime } = await import("@/features/chat/chat-realtime") + +const baseVoipCall = { + transport: "voip" as const, + whatsappCallId: "call-1", + wacid: "wacid-1", + direction: "inbound" as const, + phase: "incomingRinging" as const, + conversationId: "conversation-1", + contactInboxId: "contact-inbox-1", + contactName: "Ada Lovelace", + offer: { sdpType: "offer" as const, sdp: "v=0 offer" }, + deadlineAt: new Date(Date.now() + 20_000).toISOString(), + isMuted: false, + isRecording: false, +} + +function emit(eventType: string, data: unknown) { + capturedOnMessage?.({ data: JSON.stringify({ eventType, data }) }) +} + +describe("ChatRealtime — whatsappCallClaimedElsewhere", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + bubbleConversationToTopMock.mockResolvedValue(undefined) + authSessionMock.mockReturnValue({ data: { user: { id: "user-winner" } } }) + useWhatsappVoipCallStore.setState({ call: null }) + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + capturedOnMessage = null + }) + + const render = () => + act(() => { + root.render() + }) + + test("clears the losing agent's ringing dialog when someone else answers", async () => { + authSessionMock.mockReturnValue({ data: { user: { id: "user-loser" } } }) + useWhatsappVoipCallStore.setState({ call: baseVoipCall }) + await render() + + act(() => { + emit("whatsappCallClaimedElsewhere", { + whatsappCallId: "call-1", + wacid: "wacid-1", + answeredByUserId: "user-winner", + }) + }) + + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("the winning agent (answeredByUserId matches) ignores its own broadcast", async () => { + authSessionMock.mockReturnValue({ data: { user: { id: "user-winner" } } }) + useWhatsappVoipCallStore.setState({ + call: { ...baseVoipCall, phase: "answering" }, + }) + await render() + + act(() => { + emit("whatsappCallClaimedElsewhere", { + whatsappCallId: "call-1", + wacid: "wacid-1", + answeredByUserId: "user-winner", + }) + }) + + expect(useWhatsappVoipCallStore.getState().call).not.toBeNull() + }) + + test("ignores the event for a different call", async () => { + authSessionMock.mockReturnValue({ data: { user: { id: "user-loser" } } }) + useWhatsappVoipCallStore.setState({ call: baseVoipCall }) + await render() + + act(() => { + emit("whatsappCallClaimedElsewhere", { + whatsappCallId: "call-other", + wacid: "wacid-other", + answeredByUserId: "user-winner", + }) + }) + + expect(useWhatsappVoipCallStore.getState().call).not.toBeNull() + }) + + test("ignores the event once the local call has moved past incomingRinging (e.g. this agent is itself answering)", async () => { + authSessionMock.mockReturnValue({ data: { user: { id: "user-loser" } } }) + useWhatsappVoipCallStore.setState({ + call: { ...baseVoipCall, phase: "answering" }, + }) + await render() + + act(() => { + emit("whatsappCallClaimedElsewhere", { + whatsappCallId: "call-1", + wacid: "wacid-1", + answeredByUserId: "user-winner", + }) + }) + + expect(useWhatsappVoipCallStore.getState().call).not.toBeNull() + }) +}) + +const baseOutboundCall = { + transport: "voip" as const, + whatsappCallId: "out-call-1", + wacid: "out-wacid-1", + attemptId: "attempt-1", + phase: "outboundDialing" as const, + direction: "outbound" as const, + conversationId: "conversation-1", + contactInboxId: "contact-inbox-1", + contactName: "Ada Lovelace", + deadlineAt: new Date(Date.now() + 60_000).toISOString(), + isMuted: false, + isRecording: false, +} + +describe("ChatRealtime — outbound VoIP events", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + bubbleConversationToTopMock.mockResolvedValue(undefined) + authSessionMock.mockReturnValue({ data: { user: { id: "user-winner" } } }) + useWhatsappVoipCallStore.setState({ + call: null, + pendingOutboundAnswer: null, + }) + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + capturedOnMessage = null + }) + + const render = () => + act(() => { + root.render() + }) + + test("whatsappCallOutboundAnswer sets the pending-answer handoff without logging the SDP", async () => { + await render() + + act(() => { + emit("whatsappCallOutboundAnswer", { + whatsappCallId: "out-call-1", + wacid: "out-wacid-1", + attemptId: "attempt-1", + session: { sdpType: "answer", sdp: "v=0 answer-sdp" }, + }) + }) + + expect(useWhatsappVoipCallStore.getState().pendingOutboundAnswer).toEqual({ + whatsappCallId: "out-call-1", + sdp: "v=0 answer-sdp", + }) + }) + + test("whatsappCallOutboundStatus('ringing') moves an outboundDialing call to outboundRinging", async () => { + useWhatsappVoipCallStore.setState({ call: baseOutboundCall }) + await render() + + act(() => { + emit("whatsappCallOutboundStatus", { + whatsappCallId: "out-call-1", + wacid: "out-wacid-1", + attemptId: "attempt-1", + status: "ringing", + }) + }) + + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + "outboundRinging", + ) + }) + + test("whatsappCallOutboundStatus('accepted') moves the call to active", async () => { + useWhatsappVoipCallStore.setState({ + call: { ...baseOutboundCall, phase: "outboundRinging" }, + }) + await render() + + act(() => { + emit("whatsappCallOutboundStatus", { + whatsappCallId: "out-call-1", + wacid: "out-wacid-1", + attemptId: "attempt-1", + status: "accepted", + }) + }) + + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe("active") + }) +}) + +describe("ChatRealtime — call permission reply invalidates the outbound call mode", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + authSessionMock.mockReturnValue({ data: { user: { id: "user-winner" } } }) + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + capturedOnMessage = null + }) + + const render = () => + act(() => { + root.render() + }) + + test("a customer's accept reply refetches the button's call-mode query for that conversation", async () => { + await render() + + act(() => { + emit("messageCreated", { + id: "message-1", + conversationId: "conversation-42", + contentAttributes: { + type: "whatsapp_call_permission_reply", + response: "accept", + }, + }) + }) + + expect(invalidateQueriesMock).toHaveBeenCalledWith({ + queryKey: [ + "whatsapp-outbound-call-mode", + "workspace-1", + "conversation-42", + ], + }) + }) + + test("a plain text message does not invalidate the call-mode query", async () => { + await render() + + act(() => { + emit("messageCreated", { + id: "message-2", + conversationId: "conversation-42", + contentAttributes: { type: "text" }, + }) + }) + + expect(invalidateQueriesMock).not.toHaveBeenCalled() + }) + + test("a whatsappCallPermissionUpdated event (138017-reconciled grant) refetches the call-mode query for that conversation", async () => { + // The 138017 path records a permanent grant with no `call_permission_reply` + // message, so the button must be flipped to direct-dial off this dedicated + // event instead — see `send-message.ts` and `resolveOutboundCallMode`. + await render() + + act(() => { + emit("whatsappCallPermissionUpdated", { + conversationId: "conversation-42", + }) + }) + + expect(invalidateQueriesMock).toHaveBeenCalledWith({ + queryKey: [ + "whatsapp-outbound-call-mode", + "workspace-1", + "conversation-42", + ], + }) + }) +}) diff --git a/apps/builder/__tests__/chat-store.test.ts b/apps/builder/__tests__/chat-store.test.ts index ed6852421f..cb411c6b37 100644 --- a/apps/builder/__tests__/chat-store.test.ts +++ b/apps/builder/__tests__/chat-store.test.ts @@ -23,6 +23,11 @@ vi.mock("@/lib/orpc/orpc", () => ({ }, })) +const { loggerWarnMock } = vi.hoisted(() => ({ loggerWarnMock: vi.fn() })) +vi.mock("@/lib/log", () => ({ + logger: { warn: loggerWarnMock, error: vi.fn(), info: vi.fn() }, +})) + const { createChatStore } = await import( "../src/features/chat/store/chat-store" ) @@ -408,6 +413,76 @@ describe("chat store conversation updates", () => { agentLastReadAt: new Date("2026-01-02T00:00:00Z"), }) }) + + test("bubbleConversationToTop moves an already-loaded conversation to the front without touching lastActivityAt or the cursor", async () => { + const store = createChatStore() + const oldFirst = makeConversation( + "conv-1", + new Date("2026-01-01T00:00:00Z"), + ) + const target = makeConversation("conv-2", new Date("2026-01-01T01:00:00Z")) + store.setState({ + conversations: [oldFirst, target] as never, + nextCursorConversation: "cursor-abc", + }) + + await store.getState().bubbleConversationToTop("ws-1", "conv-2") + + const state = store.getState() + expect(state.conversations.map((c) => c.id)).toEqual(["conv-2", "conv-1"]) + // Unlike `updateConversationViaMessage`, this is a purely visual reorder: + // no fabricated `lastActivityAt` and no message payload attached. + expect(state.conversations[0].lastActivityAt).toEqual(target.lastActivityAt) + expect(state.conversations[0].messages).toEqual(target.messages) + // The server keyset cursor must never be touched by an in-memory reorder. + expect(state.nextCursorConversation).toBe("cursor-abc") + expect(mockFindConversationAuthenticatedAPI).not.toHaveBeenCalled() + }) + + test("bubbleConversationToTop fetches and prepends a conversation that isn't loaded client-side", async () => { + const store = createChatStore() + const existing = makeConversation( + "conv-1", + new Date("2026-01-01T00:00:00Z"), + ) + const fetched = makeConversation( + "conv-new", + new Date("2026-01-01T02:00:00Z"), + ) + store.setState({ conversations: [existing] as never }) + mockFindConversationAuthenticatedAPI.mockResolvedValue({ data: fetched }) + + await store.getState().bubbleConversationToTop("ws-1", "conv-new") + + expect(mockFindConversationAuthenticatedAPI).toHaveBeenCalledWith({ + workspaceId: "ws-1", + id: "conv-new", + }) + expect(store.getState().conversations).toEqual([fetched, existing]) + }) + + test("bubbleConversationToTop is a silent no-op when the conversation cannot be fetched (e.g. filtered out)", async () => { + const store = createChatStore() + const existing = makeConversation( + "conv-1", + new Date("2026-01-01T00:00:00Z"), + ) + store.setState({ conversations: [existing] as never }) + mockFindConversationAuthenticatedAPI.mockRejectedValue( + new Error("not found"), + ) + + await expect( + store.getState().bubbleConversationToTop("ws-1", "conv-missing"), + ).resolves.toBeUndefined() + expect(store.getState().conversations).toEqual([existing]) + // M-ts2 / L4: the failure is logged (not silently swallowed), even + // though it never surfaces as a toast. + expect(loggerWarnMock).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-missing" }), + expect.any(String), + ) + }) }) describe("chat store handleNewMessage read state", () => { diff --git a/apps/builder/__tests__/conversation-item.test.tsx b/apps/builder/__tests__/conversation-item.test.tsx new file mode 100644 index 0000000000..724dc39f81 --- /dev/null +++ b/apps/builder/__tests__/conversation-item.test.tsx @@ -0,0 +1,190 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import type { ListConversationItemResource } from "@/features/conversations/schema/resource" +import { useWhatsappVoipCallStore } from "@/features/integration-whatsapp/calling/voip/voip-call-store" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("next-safe-action/hooks", () => ({ + useAction: () => ({ execute: vi.fn() }), +})) + +vi.mock("@/features/contacts/utils", () => ({ + useAvatarUrl: () => undefined, +})) + +vi.mock("@/lib/auth/avatar", () => ({ + useUserAvatarUrl: () => undefined, +})) + +vi.mock( + "@/features/conversations/queries/resolve-last-message-preview", + () => ({ + resolveLastMessagePreview: () => "hello there", + }), +) + +vi.mock("@/features/conversations/utils/ad-badge", () => ({ + selectAdBadge: () => null, + adBadgeLabelKey: () => "whatsapp.calls.ringingBadge", +})) + +vi.mock("@/features/conversations/actions/read-conversation.action", () => ({ + readConversationAction: vi.fn(), +})) + +const storeState = { + activeConversationId: null as string | null, + readConversation: vi.fn(), +} +vi.mock("@/features/chat/store/chat-store-provider", () => ({ + useChatStore: (selector: (state: typeof storeState) => unknown) => + selector(storeState), +})) + +const contextMock = { + answer: vi.fn().mockResolvedValue(undefined), + dismiss: vi.fn(), + hangup: vi.fn().mockResolvedValue(undefined), + toggleMute: vi.fn(), +} +vi.mock( + "@/features/integration-whatsapp/calling/voip/whatsapp-voip-call-context", + () => ({ useWhatsappVoipCallContext: () => contextMock }), +) + +const { default: ConversationItem } = await import( + "@/features/conversations/conversation-item" +) + +const makeConversation = ( + overrides: Partial = {}, +): ListConversationItemResource => + ({ + id: "conversation-1", + workspaceId: "workspace-1", + contact: { fullName: "Ada Lovelace" }, + contactInboxes: [], + followed: false, + messages: [], + agentLastReadAt: null, + contactLastReadAt: null, + lastActivityAt: null, + assignedUserId: null, + assignedInboxTeamId: null, + ...overrides, + }) as unknown as ListConversationItemResource + +const ringingCall = { + transport: "voip" as const, + whatsappCallId: "call-1", + wacid: "wacid-1", + direction: "inbound" as const, + phase: "incomingRinging" as const, + conversationId: "conversation-1", + contactInboxId: "contact-inbox-1", + contactName: "Ada Lovelace", + offer: { sdpType: "offer" as const, sdp: "v=0 offer" }, + deadlineAt: "2026-01-01T00:00:00.000Z", + isMuted: false, + isRecording: false, +} + +describe("ConversationItem", () => { + let container: HTMLDivElement + let root: Root + const onSelect = vi.fn() + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + useWhatsappVoipCallStore.setState({ call: null }) + storeState.activeConversationId = null + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + const render = (conversation: ListConversationItemResource) => + act(() => { + root.render( + , + ) + }) + + test("no ringing badge or Answer/Reject for a non-ringing row", async () => { + await render(makeConversation()) + + expect(container.textContent).not.toContain("whatsapp.calls.ringingBadge") + expect( + container.querySelector(`[aria-label="whatsapp.calls.answer"]`), + ).toBeNull() + expect( + container.querySelector(`[aria-label="whatsapp.calls.reject"]`), + ).toBeNull() + }) + + test("shows the ringing badge and Answer/Reject only for the matching ringing conversation", async () => { + useWhatsappVoipCallStore.setState({ call: ringingCall }) + await render(makeConversation({ id: "conversation-1" })) + + expect(container.textContent).toContain("whatsapp.calls.ringingBadge") + expect( + container.querySelector(`[aria-label="whatsapp.calls.answer"]`), + ).not.toBeNull() + expect( + container.querySelector(`[aria-label="whatsapp.calls.reject"]`), + ).not.toBeNull() + }) + + test("a different (non-matching) conversation row stays unchanged while another is ringing", async () => { + useWhatsappVoipCallStore.setState({ call: ringingCall }) + await render(makeConversation({ id: "conversation-2" })) + + expect(container.textContent).not.toContain("whatsapp.calls.ringingBadge") + expect( + container.querySelector(`[aria-label="whatsapp.calls.answer"]`), + ).toBeNull() + }) + + test("Answer/Reject stopPropagation so the row is not selected, and call the shared context", async () => { + useWhatsappVoipCallStore.setState({ call: ringingCall }) + await render(makeConversation({ id: "conversation-1" })) + + const answerButton = container.querySelector( + `[aria-label="whatsapp.calls.answer"]`, + ) + act(() => { + answerButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect(contextMock.answer).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + + const rejectButton = container.querySelector( + `[aria-label="whatsapp.calls.reject"]`, + ) + act(() => { + rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect(contextMock.dismiss).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + }) + + test("clicking the row still selects the conversation as before", async () => { + await render(makeConversation()) + + const rowButton = container.querySelector("button") + act(() => { + rowButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect(onSelect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/builder/__tests__/conversation-list.test.tsx b/apps/builder/__tests__/conversation-list.test.tsx new file mode 100644 index 0000000000..209cff4056 --- /dev/null +++ b/apps/builder/__tests__/conversation-list.test.tsx @@ -0,0 +1,91 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +type VirtuosoCapturedProps = { + computeItemKey?: (index: number, item: { id: string }) => string + data: { id: string }[] +} + +const capturedProps: { current: VirtuosoCapturedProps | null } = { + current: null, +} + +vi.mock("react-virtuoso", () => ({ + Virtuoso: (props: VirtuosoCapturedProps) => { + capturedProps.current = props + return
+ }, +})) + +vi.mock("@/features/conversations/conversation-item", () => ({ + default: () =>
, +})) + +vi.mock("@/features/conversations/conversation-filter", () => ({ + ConversationFilter: () =>
, +})) + +vi.mock("@/features/contacts/create-contact-dialog", () => ({ + CreateContactDialog: () =>
, +})) + +const storeState = { + conversations: [{ id: "conv-2" }, { id: "conv-1" }] as { id: string }[], + loadMoreConversations: vi.fn().mockResolvedValue(undefined), + filters: {}, + setFilters: vi.fn(), + resetState: vi.fn(), + nextCursorConversation: null as string | null, + isLoadingConversation: false, + setActiveConversationId: vi.fn(), + initActiveConversationFromUrl: vi.fn().mockResolvedValue(undefined), +} +vi.mock("@/features/chat/store/chat-store-provider", () => ({ + useChatStore: (selector: (state: typeof storeState) => unknown) => + selector(storeState), +})) + +vi.mock("@/features/conversations/hooks/use-conversation-id-param", () => ({ + useConversationIdParam: () => ({ + set: vi.fn(), + clear: vi.fn(), + }), +})) + +const { default: ConversationList } = await import( + "@/features/conversations/conversation-list" +) + +describe("ConversationList", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + capturedProps.current = null + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + test("passes a stable computeItemKey keyed by conversation id to Virtuoso", () => { + act(() => { + root.render() + }) + + expect(capturedProps.current?.computeItemKey).toBeInstanceOf(Function) + const item = { id: "conv-42" } + expect(capturedProps.current?.computeItemKey?.(0, item)).toBe("conv-42") + }) +}) diff --git a/apps/builder/__tests__/find-contact-inbox-by-channel.test.ts b/apps/builder/__tests__/find-contact-inbox-by-channel.test.ts new file mode 100644 index 0000000000..dac772e427 --- /dev/null +++ b/apps/builder/__tests__/find-contact-inbox-by-channel.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest" +import { findContactInboxByChannel } from "@/features/conversations/utils/contact-inbox" + +const whatsappInbox = { id: "ci-wa", channel: "whatsapp" } +const messengerInbox = { id: "ci-fb", channel: "messenger" } + +describe("findContactInboxByChannel", () => { + test("returns the contact inbox on the requested channel", () => { + expect( + findContactInboxByChannel( + { contactInboxes: [messengerInbox, whatsappInbox] }, + "whatsapp", + ), + ).toBe(whatsappInbox) + }) + + test("returns undefined when the contact is not reachable on that channel", () => { + expect( + findContactInboxByChannel( + { contactInboxes: [messengerInbox] }, + "whatsapp", + ), + ).toBeUndefined() + }) + + test.each([ + ["null", null], + ["undefined", undefined], + ])("returns undefined for a %s conversation", (_, conversation) => { + expect(findContactInboxByChannel(conversation, "whatsapp")).toBeUndefined() + }) +}) diff --git a/apps/builder/__tests__/generate-call-ai-summary-action.test.ts b/apps/builder/__tests__/generate-call-ai-summary-action.test.ts new file mode 100644 index 0000000000..e185e3f340 --- /dev/null +++ b/apps/builder/__tests__/generate-call-ai-summary-action.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: { whatsappCallId: string; provider: string } +}) => Promise + +const mocks = vi.hoisted(() => ({ + getTranscriptTextForCall: vi.fn(), + attachSummary: vi.fn(), + generateCallSummary: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@chatbotx.io/ai", async () => { + const { z } = await import("zod") + return { + aiProviders: z.enum([ + "openai", + "gemini", + "claude", + "deepseek", + "openrouter", + ]), + } +}) + +vi.mock("@chatbotx.io/ai/server", () => ({ + generateCallSummary: mocks.generateCallSummary, +})) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappCallTranscriptService: { + getTranscriptTextForCall: mocks.getTranscriptTextForCall, + }, + whatsappCallSummaryService: { attachSummary: mocks.attachSummary }, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error {}, +})) + +const { generateCallAiSummaryAction } = await import( + "../src/features/messages/actions/generate-call-ai-summary.action" +) +const getAction = generateCallAiSummaryAction as unknown as ActionHandler + +describe("generateCallAiSummaryAction", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("generates via the chosen provider and persists the result", async () => { + mocks.getTranscriptTextForCall.mockResolvedValue( + "Hello there, how are you?", + ) + const aiSummary = { summary: "Friendly greeting exchange." } + mocks.generateCallSummary.mockResolvedValue(aiSummary) + mocks.attachSummary.mockResolvedValue(undefined) + + const result = await getAction({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { whatsappCallId: "call-1", provider: "openai" }, + }) + + expect(mocks.generateCallSummary).toHaveBeenCalledWith({ + workspaceId: "ws-1", + provider: "openai", + transcriptText: "Hello there, how are you?", + }) + expect(mocks.attachSummary).toHaveBeenCalledWith({ + callId: "call-1", + workspaceId: "ws-1", + aiSummary, + provider: "openai", + }) + expect(result).toEqual({ aiSummary }) + }) + + test("throws before calling the provider when the transcript is empty", async () => { + mocks.getTranscriptTextForCall.mockResolvedValue(" ") + + await expect( + getAction({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { whatsappCallId: "call-1", provider: "openai" }, + }), + ).rejects.toThrow("This call has no transcript to summarize") + + expect(mocks.generateCallSummary).not.toHaveBeenCalled() + expect(mocks.attachSummary).not.toHaveBeenCalled() + }) + + test("propagates a provider failure without persisting anything", async () => { + mocks.getTranscriptTextForCall.mockResolvedValue("Some transcript text") + mocks.generateCallSummary.mockRejectedValue(new Error("provider down")) + + await expect( + getAction({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { whatsappCallId: "call-1", provider: "claude" }, + }), + ).rejects.toThrow("provider down") + + expect(mocks.attachSummary).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/get-call-recording-url-action.test.ts b/apps/builder/__tests__/get-call-recording-url-action.test.ts new file mode 100644 index 0000000000..6ffa71bfe7 --- /dev/null +++ b/apps/builder/__tests__/get-call-recording-url-action.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: { whatsappCallId: string } +}) => Promise<{ url: string }> + +const { getRecordingUrlForCallMock } = vi.hoisted(() => ({ + getRecordingUrlForCallMock: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClientAllowExpired: chain } +}) + +vi.mock("@chatbotx.io/business", () => ({ + callRecordingService: { + getRecordingUrlForCall: getRecordingUrlForCallMock, + }, +})) + +const { getCallRecordingUrlAction } = await import( + "../src/features/messages/actions/get-call-recording-url.action" +) +const getAction = getCallRecordingUrlAction as unknown as ActionHandler + +describe("getCallRecordingUrlAction", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns a fresh signed URL for a call in the caller's workspace", async () => { + getRecordingUrlForCallMock.mockResolvedValue("https://signed.example/fresh") + + const result = await getAction({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { whatsappCallId: "call-1" }, + }) + + expect(getRecordingUrlForCallMock).toHaveBeenCalledWith({ + callId: "call-1", + workspaceId: "ws-1", + }) + expect(result).toEqual({ url: "https://signed.example/fresh" }) + }) + + test("propagates a cross-workspace rejection from the service instead of masking it", async () => { + getRecordingUrlForCallMock.mockRejectedValue( + new Error("Call recording not found"), + ) + + await expect( + getAction({ + bindArgsParsedInputs: ["ws-2"], + parsedInput: { whatsappCallId: "call-1" }, + }), + ).rejects.toThrow("Call recording not found") + }) +}) diff --git a/apps/builder/__tests__/get-call-summary-action.test.ts b/apps/builder/__tests__/get-call-summary-action.test.ts new file mode 100644 index 0000000000..7b92d90b1f --- /dev/null +++ b/apps/builder/__tests__/get-call-summary-action.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: { whatsappCallId: string } +}) => Promise<{ result: unknown }> + +const { getSummaryForCallMock } = vi.hoisted(() => ({ + getSummaryForCallMock: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClientAllowExpired: chain } +}) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappCallSummaryService: { getSummaryForCall: getSummaryForCallMock }, +})) + +const { getCallSummaryAction } = await import( + "../src/features/messages/actions/get-call-summary.action" +) +const getAction = getCallSummaryAction as unknown as ActionHandler + +describe("getCallSummaryAction", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns undefined (not an error) when no summary exists yet", async () => { + getSummaryForCallMock.mockResolvedValue(undefined) + + const result = await getAction({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { whatsappCallId: "call-1" }, + }) + + expect(result).toEqual({ result: undefined }) + }) + + test("returns the persisted summary", async () => { + const summary = { + aiSummary: { summary: "Recap" }, + aiSummaryProvider: "openai", + } + getSummaryForCallMock.mockResolvedValue(summary) + + const result = await getAction({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { whatsappCallId: "call-1" }, + }) + + expect(getSummaryForCallMock).toHaveBeenCalledWith({ + callId: "call-1", + workspaceId: "ws-1", + }) + expect(result).toEqual({ result: summary }) + }) +}) diff --git a/apps/builder/__tests__/get-call-transcript-action.test.ts b/apps/builder/__tests__/get-call-transcript-action.test.ts new file mode 100644 index 0000000000..1195e9d6f2 --- /dev/null +++ b/apps/builder/__tests__/get-call-transcript-action.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: { whatsappCallId: string } +}) => Promise + +const { getTranscriptForCallMock } = vi.hoisted(() => ({ + getTranscriptForCallMock: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClientAllowExpired: chain } +}) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappCallTranscriptService: { + getTranscriptForCall: getTranscriptForCallMock, + }, +})) + +const { getCallTranscriptAction } = await import( + "../src/features/messages/actions/get-call-transcript.action" +) +const getAction = getCallTranscriptAction as unknown as ActionHandler + +describe("getCallTranscriptAction", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("delegates to the transcript service with the caller's workspace", async () => { + const transcript = { + segments: [{ speaker: "Business", start: 0, end: 2, text: "Hi" }], + speakerNames: { business: "Agent", customer: "Contact" }, + hasSpeakers: true, + } + getTranscriptForCallMock.mockResolvedValue(transcript) + + const result = await getAction({ + bindArgsParsedInputs: ["ws-1"], + parsedInput: { whatsappCallId: "call-1" }, + }) + + expect(getTranscriptForCallMock).toHaveBeenCalledWith({ + callId: "call-1", + workspaceId: "ws-1", + }) + expect(result).toEqual(transcript) + }) + + test("propagates a cross-workspace rejection instead of masking it", async () => { + getTranscriptForCallMock.mockRejectedValue(new Error("Call not found")) + + await expect( + getAction({ + bindArgsParsedInputs: ["ws-2"], + parsedInput: { whatsappCallId: "call-1" }, + }), + ).rejects.toThrow("Call not found") + }) +}) diff --git a/apps/builder/__tests__/get-pending-incoming-voip-call-action.test.ts b/apps/builder/__tests__/get-pending-incoming-voip-call-action.test.ts new file mode 100644 index 0000000000..63a5d39917 --- /dev/null +++ b/apps/builder/__tests__/get-pending-incoming-voip-call-action.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: Record +}) => Promise + +const { getResumableIncomingMock } = vi.hoisted(() => ({ + getResumableIncomingMock: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappVoipCallService: { getResumableIncoming: getResumableIncomingMock }, +})) + +const { getPendingIncomingVoipCallAction } = await import( + "../src/features/integration-whatsapp/calling/actions/get-pending-incoming-voip-call.action" +) +const action = getPendingIncomingVoipCallAction as unknown as ActionHandler + +describe("getPendingIncomingVoipCallAction", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns the resumable incoming call for the workspace", async () => { + const pending = { + whatsappCallId: "call-1", + wacid: "wacid.ABC", + conversationId: "conv-1", + contactInboxId: "ci-1", + contactName: "Hung Phan", + offer: { sdpType: "offer" as const, sdp: "v=0..." }, + deadlineAt: "2026-09-14T00:00:00.000Z", + } + getResumableIncomingMock.mockResolvedValue(pending) + + const result = await action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: {}, + }) + + expect(getResumableIncomingMock).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + }) + expect(result).toEqual(pending) + }) + + test("returns null when there is nothing to resume", async () => { + getResumableIncomingMock.mockResolvedValue(null) + + const result = await action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: {}, + }) + + expect(result).toBeNull() + }) +}) diff --git a/apps/builder/__tests__/list-call-summary-providers-action.test.ts b/apps/builder/__tests__/list-call-summary-providers-action.test.ts new file mode 100644 index 0000000000..fe59af2fb8 --- /dev/null +++ b/apps/builder/__tests__/list-call-summary-providers-action.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] +}) => Promise<{ providers: unknown[] }> + +const { listConnectedCallSummaryProvidersMock } = vi.hoisted(() => ({ + listConnectedCallSummaryProvidersMock: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClientAllowExpired: chain } +}) + +vi.mock("@chatbotx.io/ai/server", () => ({ + listConnectedCallSummaryProviders: listConnectedCallSummaryProvidersMock, +})) + +const { listCallSummaryProvidersAction } = await import( + "../src/features/messages/actions/list-call-summary-providers.action" +) +const getAction = listCallSummaryProvidersAction as unknown as ActionHandler + +describe("listCallSummaryProvidersAction", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("returns the connected providers for the caller's workspace", async () => { + const providers = [{ id: "int-1", provider: "openai", label: "OpenAI" }] + listConnectedCallSummaryProvidersMock.mockResolvedValue(providers) + + const result = await getAction({ bindArgsParsedInputs: ["ws-1"] }) + + expect(listConnectedCallSummaryProvidersMock).toHaveBeenCalledWith("ws-1") + expect(result).toEqual({ providers }) + }) + + test("returns an empty list rather than throwing when nothing is connected", async () => { + listConnectedCallSummaryProvidersMock.mockResolvedValue([]) + + const result = await getAction({ bindArgsParsedInputs: ["ws-1"] }) + + expect(result).toEqual({ providers: [] }) + }) +}) diff --git a/apps/builder/__tests__/realtime-connect-token-api.test.ts b/apps/builder/__tests__/realtime-connect-token-api.test.ts new file mode 100644 index 0000000000..d703cbc2e1 --- /dev/null +++ b/apps/builder/__tests__/realtime-connect-token-api.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test, vi } from "vitest" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] +} + +type ProcedureHandler = (args: { + input: unknown + context: unknown +}) => Promise + +const { authorizedAPI, mocks, workspaceAuthorizedMidddleware } = vi.hoisted( + () => { + const state: { + handlers: Record + routeConfig?: RouteConfig + } = { handlers: {} } + let currentRouteName: string | undefined + + const procedure = { + route: vi.fn((config: RouteConfig) => { + currentRouteName = config.path + state.routeConfig = config + return procedure + }), + input: vi.fn(() => procedure), + use: vi.fn(() => procedure), + output: vi.fn(() => procedure), + handler: vi.fn((handler: ProcedureHandler) => { + if (currentRouteName) { + state.handlers[currentRouteName] = handler + } + return { handler } + }), + } + + return { + authorizedAPI: procedure, + mocks: { + resolveBroadcastSecret: vi.fn(), + signMemberConnectToken: vi.fn(), + state, + }, + workspaceAuthorizedMidddleware: vi.fn(), + } + }, +) + +vi.mock("@/orpc", () => ({ authorizedAPI })) +vi.mock("@/middlewares/auth", () => ({ workspaceAuthorizedMidddleware })) +vi.mock("@chatbotx.io/business", () => ({ + resolveBroadcastSecret: mocks.resolveBroadcastSecret, +})) +vi.mock("@chatbotx.io/partysocket-config/auth", () => ({ + signMemberConnectToken: mocks.signMemberConnectToken, +})) + +await import("@/features/realtime/api/private") + +const mintHandler = + mocks.state.handlers["/workspaces/{workspaceId}/realtime/connect-token"] + +describe("mintWorkspaceConnectTokenAuthenticatedAPI", () => { + test("signs a token bound to the workspace-authorized context's user and workspace, not the raw input", async () => { + mocks.resolveBroadcastSecret.mockReturnValue("the-secret") + mocks.signMemberConnectToken.mockResolvedValue("signed-token") + + const result = await mintHandler?.({ + // `workspaceId` in the input is whatever the caller sent; the + // membership-checked `context.workspace.id` is what must actually get + // signed, so make them differ to prove the handler reads context, not + // input. + input: { workspaceId: "untrusted-input-workspace" }, + context: { + user: { id: "u_1" }, + workspace: { id: "ws_1" }, + }, + }) + + expect(mocks.signMemberConnectToken).toHaveBeenCalledWith( + { workspaceId: "ws_1", userId: "u_1" }, + "the-secret", + ) + expect(mocks.resolveBroadcastSecret).toHaveBeenCalledWith({ + workspaceId: "ws_1", + }) + expect(result).toEqual({ token: "signed-token" }) + }) + + test("route is registered under the expected workspace-scoped path", () => { + expect(mocks.state.routeConfig).toMatchObject({ + method: "GET", + path: "/workspaces/{workspaceId}/realtime/connect-token", + }) + }) + + test("stays a read method so a trial-expired or MAC-limited cloud workspace can still open its inbox socket", async () => { + // `workspaceAuthorizedMidddleware` refuses any method outside + // GET/HEAD/DELETE for a blocked owner (invariant #14). Every channel's + // realtime feed connects through this token, so a mutation method here + // would black out the live inbox — not just calling. + const { isWorkspaceMutationMethod } = await import( + "@/lib/workspace/authorize-workspace-access" + ) + + expect( + isWorkspaceMutationMethod( + mocks.state.routeConfig?.method as Parameters< + typeof isWorkspaceMutationMethod + >[0], + ), + ).toBe(false) + }) +}) diff --git a/apps/builder/__tests__/reserved-custom-fields.test.ts b/apps/builder/__tests__/reserved-custom-fields.test.ts index b8f02217b4..1966392aee 100644 --- a/apps/builder/__tests__/reserved-custom-fields.test.ts +++ b/apps/builder/__tests__/reserved-custom-fields.test.ts @@ -20,6 +20,8 @@ const expectedReservedSystemFieldIds = [ systemFieldTypes.enum.workspace_id, systemFieldTypes.enum.page_user_name, systemFieldTypes.enum.last_input, + systemFieldTypes.enum.last_call_recorded, + systemFieldTypes.enum.last_call_transcript, systemFieldTypes.enum["ai.queued.messages"], systemFieldTypes.enum.current_time, systemFieldTypes.enum.booking_calendar, diff --git a/apps/builder/__tests__/use-whatsapp-voip-call.test.tsx b/apps/builder/__tests__/use-whatsapp-voip-call.test.tsx new file mode 100644 index 0000000000..77a195b09d --- /dev/null +++ b/apps/builder/__tests__/use-whatsapp-voip-call.test.tsx @@ -0,0 +1,1785 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import type { UseWhatsappVoipCallResult } from "@/features/integration-whatsapp/calling/voip/use-whatsapp-voip-call" +import { useWhatsappVoipCall } from "@/features/integration-whatsapp/calling/voip/use-whatsapp-voip-call" +import { + useWhatsappVoipCallStore, + WhatsappVoipCallPhase, +} from "@/features/integration-whatsapp/calling/voip/voip-call-store" + +vi.mock("@/hooks/routing", () => ({ + useWorkspaceId: () => "workspace-1", +})) + +const bubbleConversationToTopMock = vi.fn().mockResolvedValue(undefined) +vi.mock("@/features/chat/store/chat-store-provider", () => ({ + useChatStore: ( + selector: (state: { + bubbleConversationToTop: typeof bubbleConversationToTopMock + }) => unknown, + ) => selector({ bubbleConversationToTop: bubbleConversationToTopMock }), +})) + +vi.mock("@/lib/log", () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})) + +const { + answerActionMock, + hangupActionMock, + turnCredentialsActionMock, + pendingIncomingActionMock, + startCallRecorderMock, + initiateOutboundActionMock, + outboundTurnCredentialsActionMock, + heartbeatActiveVoipCallActionMock, +} = vi.hoisted(() => ({ + answerActionMock: vi.fn(), + hangupActionMock: vi.fn().mockResolvedValue({ data: { hungUp: true } }), + turnCredentialsActionMock: vi.fn().mockResolvedValue({ + data: { + iceServers: [{ urls: "stun:stun.example.com" }], + turnConfigured: true, + }, + }), + pendingIncomingActionMock: vi.fn().mockResolvedValue({ data: null }), + startCallRecorderMock: vi.fn(), + initiateOutboundActionMock: vi.fn(), + outboundTurnCredentialsActionMock: vi.fn().mockResolvedValue({ + data: { + iceServers: [{ urls: "stun:stun.example.com" }], + turnConfigured: true, + }, + }), + heartbeatActiveVoipCallActionMock: vi + .fn() + .mockResolvedValue({ data: { ok: true } }), +})) + +vi.mock( + "@/features/integration-whatsapp/calling/actions/answer-voip-call.action", + () => ({ answerWhatsappVoipCallAction: answerActionMock }), +) +vi.mock( + "@/features/integration-whatsapp/calling/actions/hangup-voip-call.action", + () => ({ hangupWhatsappVoipCallAction: hangupActionMock }), +) +vi.mock( + "@/features/integration-whatsapp/calling/actions/voip-turn-credentials.action", + () => ({ getWhatsappVoipTurnCredentialsAction: turnCredentialsActionMock }), +) +vi.mock( + "@/features/integration-whatsapp/calling/actions/get-pending-incoming-voip-call.action", + () => ({ getPendingIncomingVoipCallAction: pendingIncomingActionMock }), +) +vi.mock( + "@/features/integration-whatsapp/calling/actions/initiate-outbound-voip-call.action", + () => ({ initiateOutboundVoipCallAction: initiateOutboundActionMock }), +) +vi.mock( + "@/features/integration-whatsapp/calling/actions/outbound-voip-turn-credentials.action", + () => ({ + outboundVoipTurnCredentialsAction: outboundTurnCredentialsActionMock, + }), +) +vi.mock( + "@/features/integration-whatsapp/calling/actions/heartbeat-active-voip-call.action", + () => ({ + heartbeatActiveVoipCallAction: heartbeatActiveVoipCallActionMock, + }), +) +vi.mock("@/features/integration-whatsapp/calling/voip/call-recorder", () => ({ + startCallRecorder: startCallRecorderMock, +})) + +type MockTrack = { stop: ReturnType; enabled: boolean } + +const makeMockStream = () => { + const track: MockTrack = { stop: vi.fn(), enabled: true } + return { + getTracks: () => [track], + getAudioTracks: () => [track], + } as unknown as MediaStream +} + +type MockSender = { + track: MockTrack | null + replaceTrack: ReturnType +} +type MockTransceiver = { sender: MockSender } + +const createdPeerConnections: Array<{ + iceGatheringState: string + connectionState: string + localDescription: RTCSessionDescriptionInit | null + addTrack: ReturnType + addTransceiver: ReturnType + setRemoteDescription: ReturnType + createAnswer: ReturnType + createOffer: ReturnType + setLocalDescription: ReturnType + close: ReturnType + addEventListener: ReturnType + removeEventListener: ReturnType + ontrack: ((event: unknown) => void) | null + onconnectionstatechange: (() => void) | null +}> = [] + +class MockRTCPeerConnection { + iceGatheringState = "complete" + connectionState = "new" + localDescription: RTCSessionDescriptionInit | null = null + ontrack: ((event: unknown) => void) | null = null + onconnectionstatechange: (() => void) | null = null + addTrack = vi.fn() + /** Returns a fake `RTCRtpTransceiver` whose `sender.track` starts + * `null` — tests assert it stays that way until `replaceTrack` is called + * after accept. */ + addTransceiver = vi.fn( + (): MockTransceiver => ({ + sender: { + track: null, + replaceTrack: vi.fn(function replaceTrack( + this: MockSender, + track: MockTrack | null, + ) { + this.track = track + return Promise.resolve() + }), + }, + }), + ) + setRemoteDescription = vi.fn().mockResolvedValue(undefined) + createAnswer = vi + .fn() + .mockResolvedValue({ type: "answer", sdp: "v=0 answer-sdp" }) + createOffer = vi + .fn() + .mockResolvedValue({ type: "offer", sdp: "v=0 offer-sdp" }) + setLocalDescription = vi.fn().mockImplementation((desc) => { + this.localDescription = desc + return Promise.resolve() + }) + close = vi.fn() + addEventListener = vi.fn() + removeEventListener = vi.fn() + + constructor() { + createdPeerConnections.push(this) + } +} + +const getUserMediaMock = vi.fn().mockResolvedValue(makeMockStream()) + +const incomingData = { + whatsappCallId: "call-1", + wacid: "wacid-1", + conversationId: "conversation-1", + contactInboxId: "contact-inbox-1", + contactName: "Ada Lovelace", + offer: { sdpType: "offer" as const, sdp: "v=0 offer" }, + // Must stay in the future relative to the real clock: the client-side + // deadline-backstop effect arms a real `setTimeout` off this value while + // `phase === incomingRinging` and clears the store the instant it lapses. + deadlineAt: new Date(Date.now() + 60_000).toISOString(), +} + +let hookResult: UseWhatsappVoipCallResult | null = null + +describe("useWhatsappVoipCall", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + createdPeerConnections.length = 0 + vi.clearAllMocks() + bubbleConversationToTopMock.mockResolvedValue(undefined) + getUserMediaMock.mockResolvedValue(makeMockStream()) + turnCredentialsActionMock.mockResolvedValue({ + data: { + iceServers: [{ urls: "stun:stun.example.com" }], + turnConfigured: true, + }, + }) + pendingIncomingActionMock.mockResolvedValue({ data: null }) + startCallRecorderMock.mockReturnValue({ stop: vi.fn() }) + initiateOutboundActionMock.mockReset() + outboundTurnCredentialsActionMock.mockResolvedValue({ + data: { + iceServers: [{ urls: "stun:stun.example.com" }], + turnConfigured: true, + }, + }) + vi.stubGlobal("RTCPeerConnection", MockRTCPeerConnection) + vi.stubGlobal("navigator", { + ...globalThis.navigator, + mediaDevices: { getUserMedia: getUserMediaMock }, + sendBeacon: vi.fn(), + }) + + useWhatsappVoipCallStore.setState({ + call: null, + pendingOutboundAnswer: null, + }) + hookResult = null + + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + function TestHarness() { + hookResult = useWhatsappVoipCall() + return null + } + + const render = () => + act(() => { + root.render() + }) + + test("answer() gathers a peer connection, answers, and marks the call active on accepted", async () => { + answerActionMock.mockResolvedValue({ data: { outcome: "accepted" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + + await act(async () => { + await hookResult?.answer() + }) + + expect(turnCredentialsActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "call-1", + }) + expect(getUserMediaMock).toHaveBeenCalledWith({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }) + expect(createdPeerConnections).toHaveLength(1) + expect( + createdPeerConnections[0]?.setRemoteDescription, + ).toHaveBeenCalledWith({ type: "offer", sdp: "v=0 offer" }) + expect(answerActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "call-1", + sdpAnswer: "v=0 answer-sdp", + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.active, + ) + }) + + test("answer() tears down and resets on a cannotAnswer outcome", async () => { + answerActionMock.mockResolvedValue({ data: { outcome: "cannotAnswer" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + + await act(async () => { + await hookResult?.answer() + }) + + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("answer() tears down and resets on a callEnded outcome", async () => { + answerActionMock.mockResolvedValue({ data: { outcome: "callEnded" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + + await act(async () => { + await hookResult?.answer() + }) + + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("dismiss() silences the ring locally: resets the store, no peer, no server action", async () => { + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + + act(() => { + hookResult?.dismiss() + }) + + // Ring-all: dismissing is local only — no hangup/answer action is called, + // so the call keeps ringing the other agents. + expect(hangupActionMock).not.toHaveBeenCalled() + expect(answerActionMock).not.toHaveBeenCalled() + expect(createdPeerConnections).toHaveLength(0) + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("L-ts1: hangup() is a no-op while the call is still incomingRinging (never fires against an unanswered call)", async () => { + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + + await act(async () => { + await hookResult?.hangup() + }) + + expect(hangupActionMock).not.toHaveBeenCalled() + expect(createdPeerConnections).toHaveLength(0) + expect(useWhatsappVoipCallStore.getState().call).not.toBeNull() + }) + + test("hangup() closes the peer and calls the hangup action", async () => { + answerActionMock.mockResolvedValue({ data: { outcome: "accepted" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + + await act(async () => { + await hookResult?.hangup() + }) + + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "call-1", + }) + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("the transport-ended realtime event (handleEnded) closes an active peer", async () => { + answerActionMock.mockResolvedValue({ data: { outcome: "accepted" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + expect(createdPeerConnections[0]?.close).not.toHaveBeenCalled() + + act(() => { + useWhatsappVoipCallStore.getState().handleEnded("call-1") + }) + + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + }) + + test("toggleMute disables the local audio track and flips store state", async () => { + answerActionMock.mockResolvedValue({ data: { outcome: "accepted" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + + act(() => { + hookResult?.toggleMute() + }) + + expect(useWhatsappVoipCallStore.getState().call?.isMuted).toBe(true) + }) + + test("resumes a still-ringing call fetched on mount when the store slot is empty", async () => { + pendingIncomingActionMock.mockResolvedValue({ data: incomingData }) + + await render() + await act(async () => { + await pendingIncomingActionMock.mock.results.at(-1)?.value + }) + + expect(pendingIncomingActionMock).toHaveBeenCalledWith("workspace-1") + expect(useWhatsappVoipCallStore.getState().call?.whatsappCallId).toBe( + "call-1", + ) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.incomingRinging, + ) + }) + + test("the resume-after-reload path also bubbles the conversation to the top of the inbox list", async () => { + pendingIncomingActionMock.mockResolvedValue({ data: incomingData }) + + await render() + await act(async () => { + await pendingIncomingActionMock.mock.results.at(-1)?.value + }) + + expect(bubbleConversationToTopMock).toHaveBeenCalledWith( + "workspace-1", + "conversation-1", + ) + }) + + test("does not bubble when there is nothing to resume", async () => { + pendingIncomingActionMock.mockResolvedValue({ data: null }) + + await render() + await act(async () => { + await pendingIncomingActionMock.mock.results.at(-1)?.value + }) + + expect(bubbleConversationToTopMock).not.toHaveBeenCalled() + }) + + test("sends a compensating hangup and does not markActive when the store's call was cleared while answer() was in flight", async () => { + answerActionMock.mockImplementation(() => { + // Simulate the call being dismissed/cleared by the user (or a + // conflicting event) WHILE the answer round-trip is still in flight. + useWhatsappVoipCallStore.setState({ call: null }) + return Promise.resolve({ data: { outcome: "accepted" } }) + }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + + await act(async () => { + await hookResult?.answer() + }) + + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "call-1", + }) + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("does not fetch the resume lookup again on a re-render (mount-only)", async () => { + pendingIncomingActionMock.mockResolvedValue({ data: null }) + + await render() + await act(async () => { + await pendingIncomingActionMock.mock.results.at(-1)?.value + }) + expect(pendingIncomingActionMock).toHaveBeenCalledTimes(1) + + await render() + + expect(pendingIncomingActionMock).toHaveBeenCalledTimes(1) + }) + + test("skips the resume fetch entirely when a call is already in the store on mount", async () => { + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + + await render() + + expect(pendingIncomingActionMock).not.toHaveBeenCalled() + }) + + test("starts the recorder with the local+remote streams once accepted with browserRecordingEnabled", async () => { + answerActionMock.mockResolvedValue({ + data: { + outcome: "accepted", + browserRecordingEnabled: true, + recordingRequested: true, + }, + }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + + const remoteStream = makeMockStream() + act(() => { + createdPeerConnections[0]?.ontrack?.({ streams: [remoteStream] }) + }) + + expect(startCallRecorderMock).toHaveBeenCalledWith({ + whatsappCallId: "call-1", + localStream: expect.anything(), + remoteStream, + }) + expect(useWhatsappVoipCallStore.getState().call?.isRecording).toBe(true) + }) + + test("does not start the recorder when browserRecordingEnabled is false", async () => { + answerActionMock.mockResolvedValue({ + data: { + outcome: "accepted", + browserRecordingEnabled: false, + recordingRequested: false, + }, + }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + + act(() => { + createdPeerConnections[0]?.ontrack?.({ streams: [makeMockStream()] }) + }) + + expect(startCallRecorderMock).not.toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call?.isRecording).toBe(false) + }) + + test("metaNative mode never starts the browser recorder, but isRecording still reflects recordingRequested", async () => { + answerActionMock.mockResolvedValue({ + data: { + outcome: "accepted", + browserRecordingEnabled: false, + recordingRequested: true, + }, + }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + + act(() => { + createdPeerConnections[0]?.ontrack?.({ streams: [makeMockStream()] }) + }) + + expect(startCallRecorderMock).not.toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call?.isRecording).toBe(true) + }) + + test("teardown() stops the recorder before the mic track, so the final chunk is never lost", async () => { + const callOrder: string[] = [] + startCallRecorderMock.mockReturnValue({ + stop: vi.fn(() => callOrder.push("recorder-stop")), + }) + const track: MockTrack = { + stop: vi.fn(() => callOrder.push("track-stop")), + enabled: true, + } + const customStream = { + getTracks: () => [track], + getAudioTracks: () => [track], + } as unknown as MediaStream + getUserMediaMock.mockResolvedValueOnce(customStream) + + answerActionMock.mockResolvedValue({ + data: { + outcome: "accepted", + browserRecordingEnabled: true, + recordingRequested: true, + }, + }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + act(() => { + createdPeerConnections[0]?.ontrack?.({ streams: [makeMockStream()] }) + }) + expect(startCallRecorderMock).toHaveBeenCalled() + + await act(async () => { + await hookResult?.hangup() + }) + + expect(callOrder).toEqual(["recorder-stop", "track-stop"]) + }) + + test("the remote-hangup path (handleEnded) still stops the recorder", async () => { + const stopRecorderMock = vi.fn() + startCallRecorderMock.mockReturnValue({ stop: stopRecorderMock }) + answerActionMock.mockResolvedValue({ + data: { + outcome: "accepted", + browserRecordingEnabled: true, + recordingRequested: true, + }, + }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + act(() => { + createdPeerConnections[0]?.ontrack?.({ streams: [makeMockStream()] }) + }) + expect(startCallRecorderMock).toHaveBeenCalled() + + act(() => { + useWhatsappVoipCallStore.getState().handleEnded("call-1") + }) + + expect(stopRecorderMock).toHaveBeenCalled() + }) + + test("sends a best-effort hangup beacon on pagehide only while the call is active", async () => { + answerActionMock.mockResolvedValue({ + data: { + outcome: "accepted", + browserRecordingEnabled: false, + recordingRequested: false, + }, + }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + + act(() => { + window.dispatchEvent(new Event("pagehide")) + }) + + expect(navigator.sendBeacon).toHaveBeenCalledWith( + "/api/whatsapp-voip-call-hangup", + expect.any(Blob), + ) + }) + + test("R7: answer() adds a track-less sendrecv audio transceiver — sender.track stays null until accept resolves", async () => { + let resolveAnswer: ((value: unknown) => void) | undefined + answerActionMock.mockImplementation( + () => + new Promise((resolve) => { + resolveAnswer = resolve + }), + ) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + + let answerPromise: Promise | undefined + await act(async () => { + answerPromise = hookResult?.answer() + for (let i = 0; i < 20 && !resolveAnswer; i++) { + await Promise.resolve() + } + }) + + expect(createdPeerConnections[0]?.addTransceiver).toHaveBeenCalledWith( + "audio", + { direction: "sendrecv" }, + ) + const transceiver = createdPeerConnections[0]?.addTransceiver.mock + .results[0]?.value as MockTransceiver + expect(transceiver.sender.track).toBeNull() + // The single SDP answer string was already submitted to the action + // (Meta's `pre_accept`/`accept` both receive this exact string + // server-side — see `answer-voip-call.action.ts`) while the track is + // still unattached, proving no early media. + expect(answerActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "call-1", + sdpAnswer: "v=0 answer-sdp", + }) + + await act(async () => { + resolveAnswer?.({ + data: { + outcome: "accepted", + browserRecordingEnabled: false, + recordingRequested: false, + }, + }) + await answerPromise + }) + + expect(transceiver.sender.replaceTrack).toHaveBeenCalled() + expect(transceiver.sender.track).not.toBeNull() + }) + + test("R5: pc.connectionState 'failed' tears down and fires a compensating hangup with a translated connection-lost notice", async () => { + answerActionMock.mockResolvedValue({ data: { outcome: "accepted" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + const pc = createdPeerConnections[0] + expect(pc).toBeDefined() + + if (pc) { + pc.connectionState = "failed" + } + act(() => { + pc?.onconnectionstatechange?.() + }) + + expect(pc?.close).toHaveBeenCalled() + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "call-1", + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.ended, + ) + expect(useWhatsappVoipCallStore.getState().call?.endedStatus).toBe( + "connectionLost", + ) + }) + + test("R5: a failed replaceTrack after inbound accept routes through handleConnectionLost — hangs up once, tears down, stops tracks", async () => { + const stream = makeMockStream() + getUserMediaMock.mockResolvedValue(stream) + const track = stream.getTracks()[0] as unknown as MockTrack + + let resolveAnswer: ((value: unknown) => void) | undefined + answerActionMock.mockImplementation( + () => + new Promise((resolve) => { + resolveAnswer = resolve + }), + ) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + + let answerPromise: Promise | undefined + await act(async () => { + answerPromise = hookResult?.answer() + for (let i = 0; i < 20 && !resolveAnswer; i++) { + await Promise.resolve() + } + }) + + const transceiver = createdPeerConnections[0]?.addTransceiver.mock + .results[0]?.value as MockTransceiver + transceiver.sender.replaceTrack = vi + .fn() + .mockRejectedValue(new Error("replaceTrack failed")) + + await act(async () => { + resolveAnswer?.({ + data: { + outcome: "accepted", + browserRecordingEnabled: false, + recordingRequested: false, + }, + }) + await answerPromise + // Flush the rejected `replaceTrack().catch(...)` microtask and the + // subsequent `handleConnectionLost` -> `hangupWhatsappVoipCallAction` + // chain. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(hangupActionMock).toHaveBeenCalledTimes(1) + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "call-1", + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.ended, + ) + expect(useWhatsappVoipCallStore.getState().call?.endedStatus).toBe( + "connectionLost", + ) + expect(track.stop).toHaveBeenCalled() + }) + + test("R5: pc.connectionState 'disconnected' for more than the grace window tears down; recovery cancels the timer", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + answerActionMock.mockResolvedValue({ data: { outcome: "accepted" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + const pc = createdPeerConnections[0] + if (!pc) { + throw new Error("expected a peer connection") + } + + pc.connectionState = "disconnected" + act(() => pc.onconnectionstatechange?.()) + + // Recovers well before the 8s grace window elapses — must cancel the + // pending timer rather than tearing down later. + await act(async () => { + vi.advanceTimersByTime(4000) + await Promise.resolve() + }) + pc.connectionState = "connected" + act(() => pc.onconnectionstatechange?.()) + await act(async () => { + vi.advanceTimersByTime(10_000) + await Promise.resolve() + }) + expect(pc.close).not.toHaveBeenCalled() + expect(hangupActionMock).not.toHaveBeenCalled() + + // A second disconnect that is never recovered from DOES tear down + // once the grace window elapses. + pc.connectionState = "disconnected" + act(() => pc.onconnectionstatechange?.()) + await act(async () => { + vi.advanceTimersByTime(8000) + await Promise.resolve() + }) + expect(pc.close).toHaveBeenCalled() + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "call-1", + }) + } finally { + vi.useRealTimers() + } + }) + + test("R6: heartbeats every 20s while active, and a ok:false response stops the interval WITHOUT tearing the call down", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + answerActionMock.mockResolvedValue({ data: { outcome: "accepted" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + + expect(heartbeatActiveVoipCallActionMock).toHaveBeenCalledWith( + "workspace-1", + { wacid: "wacid-1" }, + ) + heartbeatActiveVoipCallActionMock.mockClear() + + await act(async () => { + vi.advanceTimersByTime(20_000) + await Promise.resolve() + }) + expect(heartbeatActiveVoipCallActionMock).toHaveBeenCalledTimes(1) + + heartbeatActiveVoipCallActionMock.mockResolvedValueOnce({ + data: { ok: false }, + }) + await act(async () => { + vi.advanceTimersByTime(20_000) + await Promise.resolve() + await Promise.resolve() + }) + heartbeatActiveVoipCallActionMock.mockClear() + + await act(async () => { + vi.advanceTimersByTime(40_000) + await Promise.resolve() + }) + expect(heartbeatActiveVoipCallActionMock).not.toHaveBeenCalled() + // ok:false only stops the ping — it must never tear the local call + // down (WebRTC media may still be flowing fine). + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.active, + ) + } finally { + vi.useRealTimers() + } + }) + + test("R6: stops heartbeating once the call ends", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + answerActionMock.mockResolvedValue({ data: { outcome: "accepted" } }) + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + await render() + await act(async () => { + await hookResult?.answer() + }) + heartbeatActiveVoipCallActionMock.mockClear() + + await act(async () => { + await hookResult?.hangup() + }) + + await act(async () => { + vi.advanceTimersByTime(60_000) + await Promise.resolve() + }) + expect(heartbeatActiveVoipCallActionMock).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) + +const outboundDialingResult = { + outcome: "dialing" as const, + whatsappCallId: "out-call-1", + wacid: "out-wacid-1", + attemptId: "attempt-1", + deadlineAt: new Date(Date.now() + 60_000).toISOString(), + browserRecordingEnabled: false, + recordingRequested: false, +} + +describe("useWhatsappVoipCall — startOutbound", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + createdPeerConnections.length = 0 + vi.clearAllMocks() + getUserMediaMock.mockResolvedValue(makeMockStream()) + outboundTurnCredentialsActionMock.mockResolvedValue({ + data: { + iceServers: [{ urls: "stun:stun.example.com" }], + turnConfigured: true, + }, + }) + pendingIncomingActionMock.mockResolvedValue({ data: null }) + startCallRecorderMock.mockReturnValue({ stop: vi.fn() }) + vi.stubGlobal("RTCPeerConnection", MockRTCPeerConnection) + vi.stubGlobal("navigator", { + ...globalThis.navigator, + mediaDevices: { getUserMedia: getUserMediaMock }, + sendBeacon: vi.fn(), + }) + vi.stubGlobal("crypto", { + ...globalThis.crypto, + randomUUID: () => "nonce-1", + }) + + useWhatsappVoipCallStore.setState({ + call: null, + pendingOutboundAnswer: null, + }) + hookResult = null + + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + function TestHarness() { + hookResult = useWhatsappVoipCall() + return null + } + + const render = () => + act(() => { + root.render() + }) + + test("dialing: builds an audio-only offer, calls the action, and moves the store to outboundDialing", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + + let outcome: string | undefined + await act(async () => { + outcome = await hookResult?.startOutbound({ + conversationId: "conversation-1", + contactName: "Ada Lovelace", + }) + }) + + expect(outboundTurnCredentialsActionMock).toHaveBeenCalledWith( + "workspace-1", + { attemptId: "nonce-1" }, + ) + expect(getUserMediaMock).toHaveBeenCalled() + expect(createdPeerConnections).toHaveLength(1) + // A track-less `sendrecv` audio transceiver is added up front, so + // `createOffer` no longer needs `offerToReceiveAudio`/`offerToReceiveVideo` + // — the transceiver alone declares the m-line, and no video transceiver + // exists, so no video m-line is ever offered. + expect(createdPeerConnections[0]?.addTransceiver).toHaveBeenCalledWith( + "audio", + { direction: "sendrecv" }, + ) + expect(createdPeerConnections[0]?.createOffer).toHaveBeenCalledWith() + expect(initiateOutboundActionMock).toHaveBeenCalledWith("workspace-1", { + conversationId: "conversation-1", + contactInboxId: undefined, + sdpOffer: "v=0 offer-sdp", + }) + expect(outcome).toBe("dialing") + expect(useWhatsappVoipCallStore.getState().call).toMatchObject({ + whatsappCallId: "out-call-1", + direction: "outbound", + phase: "outboundDialing", + }) + }) + + test("occupied: no-ops and never touches the network when the slot is already busy", async () => { + useWhatsappVoipCallStore.setState({ + call: { + transport: "voip", + whatsappCallId: "existing-call", + wacid: "existing-wacid", + direction: "inbound", + phase: "incomingRinging", + conversationId: "c", + contactInboxId: "ci", + offer: { sdpType: "offer", sdp: "v=0" }, + deadlineAt: new Date(Date.now() + 60_000).toISOString(), + isMuted: false, + isRecording: false, + }, + }) + await render() + + let outcome: string | undefined + await act(async () => { + outcome = await hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + }) + + expect(outcome).toBe("occupied") + expect(outboundTurnCredentialsActionMock).not.toHaveBeenCalled() + expect(createdPeerConnections).toHaveLength(0) + }) + + test("needsPermission: tears the unused peer down and returns the outcome without touching the store", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: { outcome: "needsPermission" }, + }) + await render() + + let outcome: string | undefined + await act(async () => { + outcome = await hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + }) + + expect(outcome).toBe("needsPermission") + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("callFailed: a thrown error during setup tears down and resolves callFailed", async () => { + getUserMediaMock.mockRejectedValueOnce(new Error("mic denied")) + await render() + + let outcome: string | undefined + await act(async () => { + outcome = await hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + }) + + expect(outcome).toBe("callFailed") + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("applies a matching pending outbound answer to the live peer, then clears it", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + act(() => { + useWhatsappVoipCallStore.getState().setPendingOutboundAnswer({ + whatsappCallId: "out-call-1", + sdp: "v=0 answer-from-meta", + }) + }) + + expect( + createdPeerConnections[0]?.setRemoteDescription, + ).toHaveBeenCalledWith({ type: "answer", sdp: "v=0 answer-from-meta" }) + expect(useWhatsappVoipCallStore.getState().pendingOutboundAnswer).toBeNull() + }) + + test("buffers a pending outbound answer that arrives before addOutbound, then applies it once the matching call appears", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + + // The Call-Connect webhook lands while the store's call slot is still + // empty (before `initiateOutboundVoipCallAction` even resolves) — must + // not be dropped. + act(() => { + useWhatsappVoipCallStore.getState().setPendingOutboundAnswer({ + whatsappCallId: "out-call-1", + sdp: "v=0 answer-from-meta", + }) + }) + expect( + useWhatsappVoipCallStore.getState().pendingOutboundAnswer, + ).not.toBeNull() + + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + expect( + createdPeerConnections[0]?.setRemoteDescription, + ).toHaveBeenCalledWith({ type: "answer", sdp: "v=0 answer-from-meta" }) + expect(useWhatsappVoipCallStore.getState().pendingOutboundAnswer).toBeNull() + }) + + test("drops a pending outbound answer for a call with no live peer (e.g. matching call resumed after a reload)", async () => { + useWhatsappVoipCallStore.setState({ + call: { + transport: "voip", + whatsappCallId: "out-call-1", + wacid: "out-wacid-1", + direction: "outbound", + phase: "outboundDialing", + conversationId: "conversation-1", + contactInboxId: "", + deadlineAt: new Date(Date.now() + 60_000).toISOString(), + isMuted: false, + isRecording: false, + }, + }) + await render() + + act(() => { + useWhatsappVoipCallStore.getState().setPendingOutboundAnswer({ + whatsappCallId: "out-call-1", + sdp: "v=0 answer-from-meta", + }) + }) + + expect(createdPeerConnections).toHaveLength(0) + expect(useWhatsappVoipCallStore.getState().pendingOutboundAnswer).toBeNull() + }) + + test("drops a pending outbound answer once a DIFFERENT call occupies the slot", async () => { + useWhatsappVoipCallStore.setState({ + call: { + transport: "voip", + whatsappCallId: "some-other-call", + wacid: "some-other-wacid", + direction: "inbound", + phase: "incomingRinging", + conversationId: "c", + contactInboxId: "ci", + offer: { sdpType: "offer", sdp: "v=0" }, + deadlineAt: new Date(Date.now() + 60_000).toISOString(), + isMuted: false, + isRecording: false, + }, + }) + await render() + + act(() => { + useWhatsappVoipCallStore.getState().setPendingOutboundAnswer({ + whatsappCallId: "out-call-1", + sdp: "v=0 answer-from-meta", + }) + }) + + expect(createdPeerConnections).toHaveLength(0) + expect(useWhatsappVoipCallStore.getState().pendingOutboundAnswer).toBeNull() + }) + + test("status-driven phase: setOutboundStatus moves outboundDialing -> outboundRinging -> active without touching pc.connectionState", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + act(() => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "ringing") + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + "outboundRinging", + ) + + act(() => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "accepted") + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe("active") + }) + + test("starts the recorder once the outbound call goes active, when browserRecordingEnabled", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: { + ...outboundDialingResult, + browserRecordingEnabled: true, + recordingRequested: true, + }, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + act(() => { + createdPeerConnections[0]?.ontrack?.({ streams: [makeMockStream()] }) + }) + + act(() => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "accepted") + }) + + expect(startCallRecorderMock).toHaveBeenCalledWith({ + whatsappCallId: "out-call-1", + localStream: expect.anything(), + remoteStream: expect.anything(), + }) + expect(useWhatsappVoipCallStore.getState().call?.isRecording).toBe(true) + }) + + test("outbound metaNative recording never starts the browser recorder, but isRecording reflects recordingRequested immediately from upgradeToDialing", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: { + ...outboundDialingResult, + browserRecordingEnabled: false, + recordingRequested: true, + }, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + // Set immediately, even before any track arrives — no MediaRecorder + // capture ever starts under metaNative. + expect(useWhatsappVoipCallStore.getState().call?.isRecording).toBe(true) + + act(() => { + createdPeerConnections[0]?.ontrack?.({ streams: [makeMockStream()] }) + }) + act(() => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "accepted") + }) + + expect(startCallRecorderMock).not.toHaveBeenCalled() + }) + + test("hangup() cancels an outbound call still outboundDialing/outboundRinging, lingering as a 'No answer' ended call rather than vanishing", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + await act(async () => { + await hookResult?.hangup() + }) + + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "out-call-1", + }) + // Lingers in `ended` (never reached active -> no `startedAt`, so the + // panel renders "No answer") rather than a bare reset(). + const { call } = useWhatsappVoipCallStore.getState() + expect(call?.phase).toBe(WhatsappVoipCallPhase.ended) + expect(call?.whatsappCallId).toBe("out-call-1") + expect(call?.startedAt).toBeUndefined() + }) + + test("the ended linger auto-clears after ~2s, and only if the slot still holds the same call", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + await act(async () => { + await hookResult?.hangup() + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.ended, + ) + + await act(async () => { + vi.advanceTimersByTime(2000) + await Promise.resolve() + }) + + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + test("an inbound ring during preparing is DROPPED — the slot is already ours from click", async () => { + let resolveInitiate: ((value: unknown) => void) | undefined + initiateOutboundActionMock.mockImplementation( + () => + new Promise((resolve) => { + resolveInitiate = resolve + }), + ) + await render() + + let outcome: string | undefined + await act(async () => { + const startPromise = hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + + // Flush the getUserMedia/createOffer/ICE-gather microtasks so + // `startOutbound` reaches the (still-pending) initiate call before the + // inbound ring lands, without relying on real timers. + for (let i = 0; i < 20 && !resolveInitiate; i++) { + await Promise.resolve() + } + + // An inbound ring arrives while this dial is still `preparing` — the + // slot is already ours (claimed instantly on click, before the + // initiate round-trip even started), so `addIncoming` drops it rather + // than claiming the slot (accepted trade-off). + useWhatsappVoipCallStore.getState().addIncoming(incomingData) + + resolveInitiate?.({ data: outboundDialingResult }) + outcome = await startPromise + }) + + expect(outcome).toBe("dialing") + // Nothing else claimed the slot, so no compensating hangup is needed — + // this is now just a normal successful dial. + expect(hangupActionMock).not.toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call?.whatsappCallId).toBe( + "out-call-1", + ) + expect(useWhatsappVoipCallStore.getState().call?.direction).toBe("outbound") + }) + + test("preparing -> cancel -> a late 'dialing' outcome fires a compensating hangup", async () => { + let resolveInitiate: ((value: unknown) => void) | undefined + initiateOutboundActionMock.mockImplementation( + () => + new Promise((resolve) => { + resolveInitiate = resolve + }), + ) + await render() + + let outcome: string | undefined + await act(async () => { + const startPromise = hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + + for (let i = 0; i < 20 && !resolveInitiate; i++) { + await Promise.resolve() + } + + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.preparing, + ) + + // The agent hits End while still `preparing` — no server call exists + // yet, so this is a purely local cancel. + await hookResult?.hangup() + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + expect(hangupActionMock).not.toHaveBeenCalled() + + // Meta ends up dialing anyway — the compensating hangup fires once + // this resolves, and the peer built for this attempt is torn down. + resolveInitiate?.({ data: outboundDialingResult }) + outcome = await startPromise + }) + + expect(outcome).toBe("cancelled") + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "out-call-1", + }) + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("preparing -> cancel before the initiate call ever resolves to dialing releases the slot with no server call", async () => { + let resolveInitiate: ((value: unknown) => void) | undefined + initiateOutboundActionMock.mockImplementation( + () => + new Promise((resolve) => { + resolveInitiate = resolve + }), + ) + await render() + + let outcome: string | undefined + await act(async () => { + const startPromise = hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + + for (let i = 0; i < 20 && !resolveInitiate; i++) { + await Promise.resolve() + } + + await hookResult?.hangup() + + // Meta reports the dial never actually connected (e.g. needsPermission) + // — no compensating hangup is needed since no live call was ever created. + resolveInitiate?.({ data: { outcome: "needsPermission" } }) + outcome = await startPromise + }) + + expect(outcome).toBe("cancelled") + expect(hangupActionMock).not.toHaveBeenCalled() + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("mic permission denied (NotAllowedError) maps to a specific outcome", async () => { + class FakeDomException extends Error { + override name = "NotAllowedError" + } + getUserMediaMock.mockRejectedValueOnce( + new FakeDomException("denied") as unknown as DOMException, + ) + vi.stubGlobal("DOMException", FakeDomException) + await render() + + let outcome: string | undefined + await act(async () => { + outcome = await hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + }) + + expect(outcome).toBe("micPermissionDenied") + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("no microphone device (NotFoundError) maps to a specific outcome", async () => { + class FakeDomException extends Error { + override name = "NotFoundError" + } + getUserMediaMock.mockRejectedValueOnce( + new FakeDomException("no device") as unknown as DOMException, + ) + vi.stubGlobal("DOMException", FakeDomException) + await render() + + let outcome: string | undefined + await act(async () => { + outcome = await hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + }) + + expect(outcome).toBe("micNotFound") + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("preparing times out after 30s and cancels the dial", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + let resolveInitiate: ((value: unknown) => void) | undefined + initiateOutboundActionMock.mockImplementation( + () => + new Promise((resolve) => { + resolveInitiate = resolve + }), + ) + await render() + + let startPromise: Promise | undefined + // Flush the getUserMedia/createOffer/ICE-gather microtasks so the call + // actually reaches `preparing` before the timer is asserted. + await act(async () => { + startPromise = hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + for (let i = 0; i < 20 && !resolveInitiate; i++) { + await Promise.resolve() + } + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.preparing, + ) + + await act(async () => { + vi.advanceTimersByTime(30_000) + await Promise.resolve() + }) + + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + + await act(async () => { + resolveInitiate?.({ data: outboundDialingResult }) + await startPromise + }) + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "out-call-1", + }) + } finally { + vi.useRealTimers() + } + }) + + test("the outbound deadline backstop ends the call server-side via hangup(), lingering as 'No answer' rather than a bare local teardown", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + const deadlineAt = new Date(Date.now() + 1000).toISOString() + initiateOutboundActionMock.mockResolvedValue({ + data: { ...outboundDialingResult, deadlineAt }, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + await act(async () => { + vi.advanceTimersByTime(1000) + // Flush the async `hangup()` -> `hangupWhatsappVoipCallAction` chain. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(createdPeerConnections[0]?.close).toHaveBeenCalled() + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "out-call-1", + }) + // Lingers as `ended` (never reached active) instead of vanishing. + const { call } = useWhatsappVoipCallStore.getState() + expect(call?.phase).toBe(WhatsappVoipCallPhase.ended) + expect(call?.whatsappCallId).toBe("out-call-1") + + // The ~2s linger then auto-clears the slot. + await act(async () => { + vi.advanceTimersByTime(2000) + await Promise.resolve() + }) + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + test("R7: the outbound offer also uses a track-less sendrecv transceiver — mic attaches only once the status reaches ACCEPTED", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + expect(createdPeerConnections[0]?.addTransceiver).toHaveBeenCalledWith( + "audio", + { direction: "sendrecv" }, + ) + const transceiver = createdPeerConnections[0]?.addTransceiver.mock + .results[0]?.value as MockTransceiver + expect(transceiver.sender.track).toBeNull() + + act(() => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "ringing") + }) + // Still ringing — no track attached yet. + expect(transceiver.sender.track).toBeNull() + + act(() => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "accepted") + }) + + expect(transceiver.sender.replaceTrack).toHaveBeenCalled() + expect(transceiver.sender.track).not.toBeNull() + }) + + test("R5: an outbound call's pc.connectionState 'failed' tears down and fires a compensating hangup", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + act(() => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "accepted") + }) + + const pc = createdPeerConnections[0] + if (!pc) { + throw new Error("expected a peer connection") + } + pc.connectionState = "failed" + act(() => pc.onconnectionstatechange?.()) + + expect(pc.close).toHaveBeenCalled() + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "out-call-1", + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.ended, + ) + expect(useWhatsappVoipCallStore.getState().call?.endedStatus).toBe( + "connectionLost", + ) + }) + + test("R5: a failed replaceTrack after outbound ACCEPTED routes through handleConnectionLost — hangs up once, tears down, stops tracks", async () => { + const stream = makeMockStream() + getUserMediaMock.mockResolvedValue(stream) + const track = stream.getTracks()[0] as unknown as MockTrack + + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + const transceiver = createdPeerConnections[0]?.addTransceiver.mock + .results[0]?.value as MockTransceiver + transceiver.sender.replaceTrack = vi + .fn() + .mockRejectedValue(new Error("replaceTrack failed")) + + await act(async () => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "accepted") + // Flush the rejected `replaceTrack().catch(...)` microtask and the + // subsequent `handleConnectionLost` -> `hangupWhatsappVoipCallAction` + // chain. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(hangupActionMock).toHaveBeenCalledTimes(1) + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "out-call-1", + }) + expect(useWhatsappVoipCallStore.getState().call?.phase).toBe( + WhatsappVoipCallPhase.ended, + ) + expect(useWhatsappVoipCallStore.getState().call?.endedStatus).toBe( + "connectionLost", + ) + expect(track.stop).toHaveBeenCalled() + }) + + test("R5: a replaceTrack failure followed by a connectionstatechange 'failed' still hangs up only once", async () => { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + + const transceiver = createdPeerConnections[0]?.addTransceiver.mock + .results[0]?.value as MockTransceiver + transceiver.sender.replaceTrack = vi + .fn() + .mockRejectedValue(new Error("replaceTrack failed")) + + await act(async () => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "accepted") + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + expect(hangupActionMock).toHaveBeenCalledTimes(1) + + const pc = createdPeerConnections[0] + if (!pc) { + throw new Error("expected a peer connection") + } + pc.connectionState = "failed" + await act(async () => { + pc.onconnectionstatechange?.() + await Promise.resolve() + }) + + // Still only once — `handleConnectionLost` must be idempotent once the + // store already shows the call `ended`. + expect(hangupActionMock).toHaveBeenCalledTimes(1) + }) + + test("R5: a connection failure while still preparing releases the local slot immediately (before any server call exists)", async () => { + let resolveInitiate: ((value: unknown) => void) | undefined + initiateOutboundActionMock.mockImplementation( + () => + new Promise((resolve) => { + resolveInitiate = resolve + }), + ) + await render() + + await act(async () => { + const startPromise = hookResult?.startOutbound({ + conversationId: "conversation-1", + }) + for (let i = 0; i < 20 && !resolveInitiate; i++) { + await Promise.resolve() + } + + // The connection fails while no server call exists yet (still + // `preparing`) — releases the slot right away, with no server call to + // hang up against at this point. + const pc = createdPeerConnections[0] + if (pc) { + pc.connectionState = "failed" + pc.onconnectionstatechange?.() + } + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + expect(hangupActionMock).not.toHaveBeenCalled() + + // Meta ends up dialing anyway (a late resolution) — same defense-in-depth + // compensating hangup as an agent-initiated cancel racing a late + // "dialing" outcome (see the `hangup()`-during-`preparing` test above): + // the slot is already gone, so the dial's own post-upgrade check fires + // the compensating hangup itself. + resolveInitiate?.({ data: outboundDialingResult }) + await startPromise + }) + + expect(hangupActionMock).toHaveBeenCalledWith("workspace-1", { + whatsappCallId: "out-call-1", + }) + expect(useWhatsappVoipCallStore.getState().call).toBeNull() + }) + + test("R6: heartbeats an outbound call every 20s once active, keyed by wacid", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + initiateOutboundActionMock.mockResolvedValue({ + data: outboundDialingResult, + }) + await render() + await act(async () => { + await hookResult?.startOutbound({ conversationId: "conversation-1" }) + }) + act(() => { + useWhatsappVoipCallStore + .getState() + .setOutboundStatus("out-call-1", "accepted") + }) + + expect(heartbeatActiveVoipCallActionMock).toHaveBeenCalledWith( + "workspace-1", + { wacid: "out-wacid-1" }, + ) + heartbeatActiveVoipCallActionMock.mockClear() + + await act(async () => { + vi.advanceTimersByTime(20_000) + await Promise.resolve() + }) + expect(heartbeatActiveVoipCallActionMock).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/apps/builder/__tests__/webhook-log.test.ts b/apps/builder/__tests__/webhook-log.test.ts index 7a49d89d2f..436a80f9b4 100644 --- a/apps/builder/__tests__/webhook-log.test.ts +++ b/apps/builder/__tests__/webhook-log.test.ts @@ -48,6 +48,39 @@ describe("sanitizeWebhookBody", () => { expect(result.user.name).toBe("alice") }) + test("redacts the SDP value nested in a WhatsApp calling session (the `sdp` key, not the whole session)", () => { + const sentinel = "v=0 SENTINEL-SDP-OFFER-DO-NOT-LOG" + const body = JSON.stringify({ + entry: [ + { + changes: [ + { + field: "calls", + value: { + calls: [ + { + id: "wacid.ABC", + event: "connect", + session: { sdp_type: "offer", sdp: sentinel }, + }, + ], + }, + }, + ], + }, + ], + }) + + const result = sanitizeWebhookBody(body) + + // Only the `sdp` value is redacted — the `session` wrapper is kept (adding + // `session` to the global key set was too broad a blast radius; the SDP is + // the secret). See `packages/logger/src/redact.ts`. + expect(result).not.toContain(sentinel) + expect(result).toContain('"sdp":"[redacted]"') + expect(result).toContain('"sdp_type":"offer"') + }) + test("omits a non-JSON body instead of logging it raw", () => { const body = "access_token=super-secret&foo=bar" @@ -113,6 +146,44 @@ describe("logWebhookRequestBody", () => { ) }) + test("never emits a WhatsApp calling SDP offer through the diagnostic payload", async () => { + loggerInfo.mockClear() + loggerDebug.mockClear() + const sentinel = "v=0 SENTINEL-SDP-OFFER-DO-NOT-LOG" + const rawBody = JSON.stringify({ + entry: [ + { + changes: [ + { + field: "calls", + value: { + calls: [ + { + id: "wacid.ABC", + event: "connect", + session: { sdp_type: "offer", sdp: sentinel }, + }, + ], + }, + }, + ], + }, + ], + }) + const req = { + clone: () => ({ text: () => Promise.resolve(rawBody) }), + } as unknown as Parameters[1] + + await logWebhookRequestBody("whatsapp", req) + + expect(loggerDebug).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.not.stringContaining(sentinel), + }), + "Webhook request body payload", + ) + }) + test("logs the metadata line and the sanitized diagnostic payload", async () => { loggerInfo.mockClear() loggerDebug.mockClear() diff --git a/apps/builder/__tests__/whatsapp-answer-voip-call-action.test.ts b/apps/builder/__tests__/whatsapp-answer-voip-call-action.test.ts new file mode 100644 index 0000000000..2d8fadaf5c --- /dev/null +++ b/apps/builder/__tests__/whatsapp-answer-voip-call-action.test.ts @@ -0,0 +1,657 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: Record + ctx: { user: { id: string } } +}) => Promise + +const { + findByIdMock, + findByInboxIdForWorkspaceMock, + findContactInboxMock, + findContactMock, + markAcceptedByAgentMock, + readControlMock, + claimForAnswerMock, + commitAcceptedMock, + releaseClaimMock, + preAcceptCallMock, + acceptCallMock, + terminateCallMock, + broadcastToWorkspacePartyMock, +} = vi.hoisted(() => ({ + findByIdMock: vi.fn(), + findByInboxIdForWorkspaceMock: vi.fn(), + findContactInboxMock: vi.fn(), + findContactMock: vi.fn(), + markAcceptedByAgentMock: vi.fn(), + readControlMock: vi.fn(), + claimForAnswerMock: vi.fn(), + commitAcceptedMock: vi.fn(), + releaseClaimMock: vi.fn(), + preAcceptCallMock: vi.fn(), + acceptCallMock: vi.fn(), + terminateCallMock: vi.fn(), + broadcastToWorkspacePartyMock: vi.fn(), +})) + +const DEADLINE_SAFETY_MARGIN_MS = 3000 + +class FakeWhatsappException extends Error { + httpStatusCode: number + constructor(message: string, httpStatusCode = 400) { + super(message) + this.httpStatusCode = httpStatusCode + } +} + +const SUPPORTED_ANNOUNCEMENT_LANGUAGES = new Set([ + "en", + "en_US", + "en_AU", + "en_CA", + "en_GB", + "en_IN", + "en_NZ", + "nl", + "fr", + "de", + "hi", + "it", + "kn", + "pt", + "es", + "es_ES", + "te", + "vi", +]) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@/lib/log", () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappVoipCallService: { + readControl: readControlMock, + claimForAnswer: claimForAnswerMock, + commitAccepted: commitAcceptedMock, + releaseClaim: releaseClaimMock, + markAcceptedByAgent: markAcceptedByAgentMock, + }, + // Real implementation (not a stub) so the deadline tests exercise the + // actual deadline-margin logic instead of a hard-coded true/false. + isAnswerDeadlineExpired: (deadlineAt: number) => + Date.now() + DEADLINE_SAFETY_MARGIN_MS >= deadlineAt, + contactInboxService: { findBy: findContactInboxMock }, + contactService: { findBy: findContactMock }, + broadcastToWorkspaceParty: broadcastToWorkspacePartyMock, +})) + +vi.mock("@chatbotx.io/partysocket-config", () => ({ + RealtimeEventType: { + whatsappCallClaimedElsewhere: "whatsappCallClaimedElsewhere", + }, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error {}, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { + findByInboxIdForWorkspace: findByInboxIdForWorkspaceMock, + }, + whatsappCallRepository: { + findById: findByIdMock, + }, +})) + +vi.mock("@chatbotx.io/integration-whatsapp/api/calling", () => ({ + preAcceptCall: preAcceptCallMock, + acceptCall: acceptCallMock, + terminateCall: terminateCallMock, + resolveAnnouncementLanguage: (input: string | undefined) => + input && SUPPORTED_ANNOUNCEMENT_LANGUAGES.has(input) ? input : "en_US", +})) + +vi.mock("@chatbotx.io/integration-whatsapp/exception", () => ({ + WhatsappException: FakeWhatsappException, +})) + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string) => key, +})) + +const { answerWhatsappVoipCallAction } = await import( + "../src/features/integration-whatsapp/calling/actions/answer-voip-call.action" +) +const action = answerWhatsappVoipCallAction as unknown as ActionHandler + +const ctx = { user: { id: "agent-1" } } + +const call = (whatsappCallId = "call-1", sdpAnswer = "v=0 answer") => + action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: { whatsappCallId, sdpAnswer }, + ctx, + }) + +describe("answerWhatsappVoipCallAction", () => { + beforeEach(() => { + vi.clearAllMocks() + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + contactInboxId: "contact-inbox-1", + wacid: "wacid-1", + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: { tokens: { accessToken: "tok" } }, + callRecordingEnabled: false, + callTranscriptionEnabled: false, + callRecordingMode: "browserWhisper", + callTranscriptionMode: "browserWhisper", + callAnnouncementLanguage: null, + callRecordingPurpose: null, + }) + findContactInboxMock.mockResolvedValue({ + id: "contact-inbox-1", + contactId: "contact-1", + }) + findContactMock.mockResolvedValue({ id: "contact-1", locale: null }) + readControlMock.mockResolvedValue({ + reservedUserId: "agent-1", + phase: "reserved", + deadlineAt: Date.now() + 60_000, + fenceToken: "fence-1", + }) + claimForAnswerMock.mockResolvedValue("fence-1") + preAcceptCallMock.mockResolvedValue(undefined) + acceptCallMock.mockResolvedValue(undefined) + commitAcceptedMock.mockResolvedValue(true) + markAcceptedByAgentMock.mockResolvedValue(true) + terminateCallMock.mockResolvedValue(undefined) + releaseClaimMock.mockResolvedValue(true) + broadcastToWorkspacePartyMock.mockResolvedValue(undefined) + }) + + test("denies a cross-workspace call id", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-2", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + await expect(call()).rejects.toThrow("whatsapp.calls.errors.callNotFound") + expect(claimForAnswerMock).not.toHaveBeenCalled() + }) + + test("answers successfully: pre_accept then accept then guarded persist", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + + const result = await call() + + expect(claimForAnswerMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + userId: "agent-1", + }) + const preOrder = preAcceptCallMock.mock.invocationCallOrder[0] + const acceptOrder = acceptCallMock.mock.invocationCallOrder[0] + expect(preOrder).toBeLessThan(acceptOrder) + expect(preAcceptCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1", sdpAnswer: "v=0 answer" }), + ) + expect(commitAcceptedMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + fenceToken: "fence-1", + }) + expect(markAcceptedByAgentMock).toHaveBeenCalledWith({ + whatsappCallId: "call-1", + agentUserId: "agent-1", + }) + expect(terminateCallMock).not.toHaveBeenCalled() + expect(result).toEqual({ + outcome: "accepted", + browserRecordingEnabled: false, + recordingRequested: false, + }) + expect(broadcastToWorkspacePartyMock).toHaveBeenCalledWith("workspace-1", { + eventType: "whatsappCallClaimedElsewhere", + data: { + whatsappCallId: "call-1", + wacid: "wacid-1", + answeredByUserId: "agent-1", + }, + }) + }) + + test("releases the fenced claim (compensation) and does not swallow the accept error when Graph accept fails", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + acceptCallMock.mockRejectedValue(new Error("graph accept 500")) + + await expect(call()).rejects.toThrow( + "whatsapp.calls.errors.voipAnswerFailed", + ) + + expect(releaseClaimMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + fenceToken: "fence-1", + }) + expect(commitAcceptedMock).not.toHaveBeenCalled() + expect(broadcastToWorkspacePartyMock).not.toHaveBeenCalled() + }) + + test("a releaseClaim failure never masks the original accept error", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + acceptCallMock.mockRejectedValue(new Error("graph accept 500")) + releaseClaimMock.mockRejectedValue(new Error("redis down")) + + await expect(call()).rejects.toThrow( + "whatsapp.calls.errors.voipAnswerFailed", + ) + + expect(releaseClaimMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + fenceToken: "fence-1", + }) + }) + + test("returns browserRecordingEnabled+recordingRequested true when recording is on and mode is browserWhisper", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: { tokens: { accessToken: "tok" } }, + callRecordingEnabled: true, + callRecordingMode: "browserWhisper", + }) + + const result = await call() + + expect(result).toEqual({ + outcome: "accepted", + browserRecordingEnabled: true, + recordingRequested: true, + }) + }) + + test("metaNative recording never enables the browser recorder, but still reports recordingRequested", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: { tokens: { accessToken: "tok" } }, + callRecordingEnabled: true, + callRecordingMode: "metaNative", + callAnnouncementLanguage: "en_US", + callRecordingPurpose: "QA", + }) + + const result = await call() + + expect(result).toEqual({ + outcome: "accepted", + browserRecordingEnabled: false, + recordingRequested: true, + }) + }) + + test("returns cannotAnswer when the claim is lost, without touching Graph or the DB", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + claimForAnswerMock.mockResolvedValue(null) + + const result = await call() + + expect(result).toEqual({ outcome: "cannotAnswer" }) + expect(preAcceptCallMock).not.toHaveBeenCalled() + expect(acceptCallMock).not.toHaveBeenCalled() + expect(markAcceptedByAgentMock).not.toHaveBeenCalled() + }) + + test("compensates with terminate and never persists accepted when the commit is lost", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + commitAcceptedMock.mockResolvedValue(false) + + const result = await call() + + expect(result).toEqual({ outcome: "callEnded" }) + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(markAcceptedByAgentMock).not.toHaveBeenCalled() + }) + + test("compensates with terminate when the guarded DB persist matches 0 rows (row already terminal)", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + // Redis commit won, but a concurrent hangup finalized the row first. + markAcceptedByAgentMock.mockResolvedValue(false) + + const result = await call() + + expect(result).toEqual({ outcome: "callEnded" }) + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + }) + + test("never logs the SDP answer on a Graph failure", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + preAcceptCallMock.mockRejectedValue(new Error("graph failed")) + const { logger } = await import("@/lib/log") + + await expect(call()).rejects.toThrow( + "whatsapp.calls.errors.voipAnswerFailed", + ) + + const loggedText = JSON.stringify( + (logger.error as ReturnType).mock.calls, + ) + expect(loggedText).not.toContain("v=0 answer") + expect(commitAcceptedMock).not.toHaveBeenCalled() + }) + + describe("R16: server-side answer-deadline enforcement", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test("before claim: an already-expired deadline returns cannotAnswer without calling claimForAnswer or Graph", async () => { + readControlMock.mockResolvedValue({ + reservedUserId: "", + phase: "reserved", + deadlineAt: Date.now() - 1, + fenceToken: "fence-1", + }) + + const result = await call() + + expect(result).toEqual({ outcome: "cannotAnswer" }) + expect(claimForAnswerMock).not.toHaveBeenCalled() + expect(preAcceptCallMock).not.toHaveBeenCalled() + expect(acceptCallMock).not.toHaveBeenCalled() + }) + + test("before claim: a deadline within the 3s safety margin returns cannotAnswer", async () => { + readControlMock.mockResolvedValue({ + reservedUserId: "", + phase: "reserved", + deadlineAt: Date.now() + 2000, + fenceToken: "fence-1", + }) + + const result = await call() + + expect(result).toEqual({ outcome: "cannotAnswer" }) + expect(claimForAnswerMock).not.toHaveBeenCalled() + }) + + test("before pre_accept: the deadline expires between claim and pre_accept, releases the claim, never calls Graph", async () => { + const deadlineAt = Date.now() + 3500 + readControlMock.mockResolvedValue({ + reservedUserId: "", + phase: "reserved", + deadlineAt, + fenceToken: "fence-1", + }) + claimForAnswerMock.mockImplementation(() => { + vi.advanceTimersByTime(1000) + return Promise.resolve("fence-1") + }) + + const result = await call() + + expect(result).toEqual({ outcome: "cannotAnswer" }) + expect(releaseClaimMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + fenceToken: "fence-1", + }) + expect(preAcceptCallMock).not.toHaveBeenCalled() + expect(acceptCallMock).not.toHaveBeenCalled() + }) + + test("before accept: the deadline expires between pre_accept and accept, releases the claim, never calls accept", async () => { + const deadlineAt = Date.now() + 4000 + readControlMock.mockResolvedValue({ + reservedUserId: "", + phase: "reserved", + deadlineAt, + fenceToken: "fence-1", + }) + preAcceptCallMock.mockImplementation(() => { + vi.advanceTimersByTime(1500) + return Promise.resolve() + }) + + const result = await call() + + expect(result).toEqual({ outcome: "cannotAnswer" }) + expect(preAcceptCallMock).toHaveBeenCalledTimes(1) + expect(acceptCallMock).not.toHaveBeenCalled() + expect(releaseClaimMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + fenceToken: "fence-1", + }) + }) + + test("maps a Graph accept failure that races past the deadline to cannotAnswer instead of throwing", async () => { + const deadlineAt = Date.now() + 4000 + readControlMock.mockResolvedValue({ + reservedUserId: "", + phase: "reserved", + deadlineAt, + fenceToken: "fence-1", + }) + acceptCallMock.mockImplementation(() => { + vi.advanceTimersByTime(1500) + return Promise.reject(new Error("graph accept timed out")) + }) + + const result = await call() + + expect(result).toEqual({ outcome: "cannotAnswer" }) + expect(releaseClaimMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + fenceToken: "fence-1", + }) + }) + + test("well within the deadline: answers normally", async () => { + readControlMock.mockResolvedValue({ + reservedUserId: "", + phase: "reserved", + deadlineAt: Date.now() + 55_000, + fenceToken: "fence-1", + }) + + const result = await call() + + expect(result).toMatchObject({ outcome: "accepted" }) + }) + }) + + describe("Meta-native recording/transcription announcement options", () => { + const metaNativeIntegration = { + id: "integration-1", + auth: { tokens: { accessToken: "tok" } }, + callRecordingEnabled: true, + callTranscriptionEnabled: true, + callRecordingMode: "metaNative", + callTranscriptionMode: "metaNative", + callAnnouncementLanguage: "fr", + callRecordingPurpose: "Quality assurance", + } + + test("passes recording/transcription options to acceptCall when both are metaNative", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue(metaNativeIntegration) + + await call() + + const expectedAnnouncement = { + status: "ENABLED", + purpose: "Quality assurance", + announcementLanguage: "fr", + } + expect(acceptCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + recording: expectedAnnouncement, + transcription: expectedAnnouncement, + }), + ) + }) + + test("omits both options in browserWhisper mode even when the toggles are on", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + ...metaNativeIntegration, + callRecordingMode: "browserWhisper", + callTranscriptionMode: "browserWhisper", + }) + + await call() + + const acceptArgs = acceptCallMock.mock.calls[0]?.[0] as Record< + string, + unknown + > + expect(acceptArgs.recording).toBeUndefined() + expect(acceptArgs.transcription).toBeUndefined() + }) + + test("retries acceptCall once without announcement options after a Meta 4xx", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue(metaNativeIntegration) + acceptCallMock + .mockRejectedValueOnce( + new FakeWhatsappException("invalid purpose", 400), + ) + .mockResolvedValueOnce(undefined) + const { logger } = await import("@/lib/log") + + const result = await call() + + expect(result).toEqual({ + outcome: "accepted", + browserRecordingEnabled: false, + recordingRequested: true, + }) + expect(acceptCallMock).toHaveBeenCalledTimes(2) + const secondCallArgs = acceptCallMock.mock.calls[1]?.[0] as Record< + string, + unknown + > + expect(secondCallArgs.recording).toBeUndefined() + expect(secondCallArgs.transcription).toBeUndefined() + expect(logger.warn).toHaveBeenCalled() + expect(commitAcceptedMock).toHaveBeenCalled() + }) + + test("does not retry, and surfaces the failure, when acceptCall fails with a non-4xx error", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue(metaNativeIntegration) + acceptCallMock.mockRejectedValue( + new FakeWhatsappException("Meta is down", 502), + ) + + await expect(call()).rejects.toThrow( + "whatsapp.calls.errors.voipAnswerFailed", + ) + expect(acceptCallMock).toHaveBeenCalledTimes(1) + }) + + test("uses the contact's locale for the announcement language when the integration has none configured", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + ...metaNativeIntegration, + callAnnouncementLanguage: null, + }) + findContactMock.mockResolvedValue({ id: "contact-1", locale: "es" }) + + await call() + + expect(acceptCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + recording: expect.objectContaining({ announcementLanguage: "es" }), + }), + ) + }) + + test("prefers the per-channel ContactInbox.language (the contact panel's 'Language' field) over the derived Contact.locale", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + ...metaNativeIntegration, + callAnnouncementLanguage: null, + }) + // Agent set the contact's Language to English; the auto-derived locale is + // still the WhatsApp profile's other language — English must win. + findContactInboxMock.mockResolvedValue({ + id: "contact-inbox-1", + contactId: "contact-1", + language: "en", + }) + findContactMock.mockResolvedValue({ id: "contact-1", locale: "fr" }) + + await call() + + expect(acceptCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + recording: expect.objectContaining({ announcementLanguage: "en" }), + }), + ) + }) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-blocked-outbound-countries.test.ts b/apps/builder/__tests__/whatsapp-blocked-outbound-countries.test.ts new file mode 100644 index 0000000000..c4f9058d81 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-blocked-outbound-countries.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "vitest" +import { BLOCKED_OUTBOUND_COUNTRIES } from "../src/features/integration-whatsapp/calling/actions/blocked-outbound-countries" + +describe("BLOCKED_OUTBOUND_COUNTRIES", () => { + test("matches Meta's business-initiated calling block list exactly", () => { + expect([...BLOCKED_OUTBOUND_COUNTRIES].sort()).toEqual( + ["CA", "EG", "NG", "US", "VN"].sort(), + ) + }) + + test("does not include Turkey (TR)", () => { + expect(BLOCKED_OUTBOUND_COUNTRIES.has("TR")).toBe(false) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-call-ai-summary-dialog.test.tsx b/apps/builder/__tests__/whatsapp-call-ai-summary-dialog.test.tsx new file mode 100644 index 0000000000..ba018b555a --- /dev/null +++ b/apps/builder/__tests__/whatsapp-call-ai-summary-dialog.test.tsx @@ -0,0 +1,183 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("@/hooks/routing", () => ({ + useWorkspaceId: () => "ws-1", +})) + +const { listCallSummaryProvidersActionMock, generateCallAiSummaryActionMock } = + vi.hoisted(() => ({ + listCallSummaryProvidersActionMock: vi.fn(), + generateCallAiSummaryActionMock: vi.fn(), + })) + +vi.mock( + "@/features/messages/actions/list-call-summary-providers.action", + () => ({ + listCallSummaryProvidersAction: listCallSummaryProvidersActionMock, + }), +) + +vi.mock("@/features/messages/actions/generate-call-ai-summary.action", () => ({ + generateCallAiSummaryAction: Object.assign(generateCallAiSummaryActionMock, { + bind: () => generateCallAiSummaryActionMock, + }), +})) + +const { WhatsappCallAiSummaryDialog } = await import( + "@/features/messages/components/whatsapp-call-ai-summary-dialog" +) + +let container: HTMLDivElement | null = null +let root: Root | null = null +let queryClient: QueryClient + +function renderComponent(props: { + open: boolean + hasTranscript: boolean + onGenerated: (result: unknown) => void + onOpenChange: (open: boolean) => void +}) { + queryClient = new QueryClient() + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + + , + ) + }) +} + +const flush = async () => { + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + } +} + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + container = null + root = null +}) + +describe("WhatsappCallAiSummaryDialog", () => { + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + }) + + test("lists the workspace's connected AI providers", async () => { + listCallSummaryProvidersActionMock.mockResolvedValue({ + data: { + providers: [ + { id: "int-1", provider: "openai", label: "OpenAI" }, + { id: "int-2", provider: "claude", label: "Claude" }, + ], + }, + }) + + renderComponent({ + open: true, + hasTranscript: true, + onGenerated: vi.fn(), + onOpenChange: vi.fn(), + }) + await flush() + + expect(document.body.textContent).toContain("OpenAI") + expect(document.body.textContent).toContain("Claude") + }) + + test("shows a connect-an-AI-provider link when nothing is connected", async () => { + listCallSummaryProvidersActionMock.mockResolvedValue({ + data: { providers: [] }, + }) + + renderComponent({ + open: true, + hasTranscript: true, + onGenerated: vi.fn(), + onOpenChange: vi.fn(), + }) + await flush() + + expect(document.body.textContent).toContain("noProviderTitle") + const link = document.body.querySelector("a") + expect(link?.getAttribute("href")).toBe("/space/ws-1/settings/integrations") + }) + + test("clicking Generate calls generateCallAiSummaryAction with the selected provider", async () => { + listCallSummaryProvidersActionMock.mockResolvedValue({ + data: { + providers: [{ id: "int-1", provider: "openai", label: "OpenAI" }], + }, + }) + generateCallAiSummaryActionMock.mockResolvedValue({ + data: { aiSummary: { summary: "Recap" } }, + }) + const onGenerated = vi.fn() + + renderComponent({ + open: true, + hasTranscript: true, + onGenerated, + onOpenChange: vi.fn(), + }) + await flush() + + const generateButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent === "generate") + expect(generateButton).toBeDefined() + + await act(async () => { + generateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(generateCallAiSummaryActionMock).toHaveBeenCalledWith({ + whatsappCallId: "call-1", + provider: "openai", + }) + expect(onGenerated).toHaveBeenCalledWith({ summary: "Recap" }) + }) + + test("disables Generate with a tooltip when the transcript is empty", async () => { + listCallSummaryProvidersActionMock.mockResolvedValue({ + data: { + providers: [{ id: "int-1", provider: "openai", label: "OpenAI" }], + }, + }) + + renderComponent({ + open: true, + hasTranscript: false, + onGenerated: vi.fn(), + onOpenChange: vi.fn(), + }) + await flush() + + const generateButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent === "generate") + expect(generateButton?.disabled).toBe(true) + expect(generateCallAiSummaryActionMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-call-announcement-options.test.ts b/apps/builder/__tests__/whatsapp-call-announcement-options.test.ts new file mode 100644 index 0000000000..c37a0d378f --- /dev/null +++ b/apps/builder/__tests__/whatsapp-call-announcement-options.test.ts @@ -0,0 +1,206 @@ +// @vitest-environment node + +import { describe, expect, test } from "vitest" +import { + buildCallAnnouncementOptions, + type CallAnnouncementIntegration, + DEFAULT_CALL_ANNOUNCEMENT_PURPOSE, + hasCallAnnouncementOptions, + isCallAnnouncementValidationError, +} from "../src/features/integration-whatsapp/calling/actions/call-announcement-options" + +const baseIntegration: CallAnnouncementIntegration = { + callRecordingEnabled: false, + callTranscriptionEnabled: false, + callRecordingMode: "metaNative", + callTranscriptionMode: "metaNative", + callAnnouncementLanguage: null, + callRecordingPurpose: null, +} + +describe("buildCallAnnouncementOptions", () => { + test("returns an empty object when both toggles are off", () => { + expect(buildCallAnnouncementOptions(baseIntegration)).toEqual({}) + }) + + test("omits recording when the toggle is on but the mode is browserWhisper", () => { + const result = buildCallAnnouncementOptions({ + ...baseIntegration, + callRecordingEnabled: true, + callRecordingMode: "browserWhisper", + }) + expect(result).toEqual({}) + }) + + test("omits transcription when the toggle is on but the mode is browserWhisper", () => { + const result = buildCallAnnouncementOptions({ + ...baseIntegration, + callTranscriptionEnabled: true, + callTranscriptionMode: "browserWhisper", + }) + expect(result).toEqual({}) + }) + + test("attaches only recording when enabled+metaNative and transcription is off", () => { + const result = buildCallAnnouncementOptions({ + ...baseIntegration, + callRecordingEnabled: true, + }) + expect(result.recording).toEqual({ + status: "ENABLED", + purpose: DEFAULT_CALL_ANNOUNCEMENT_PURPOSE, + announcementLanguage: "en_US", + }) + expect(result.transcription).toBeUndefined() + }) + + test("attaches only transcription when enabled+metaNative and recording is off", () => { + const result = buildCallAnnouncementOptions({ + ...baseIntegration, + callTranscriptionEnabled: true, + }) + expect(result.transcription).toEqual({ + status: "ENABLED", + purpose: DEFAULT_CALL_ANNOUNCEMENT_PURPOSE, + announcementLanguage: "en_US", + }) + expect(result.recording).toBeUndefined() + }) + + test("attaches both with a single shared purpose/announcementLanguage when both are enabled+metaNative", () => { + const result = buildCallAnnouncementOptions({ + ...baseIntegration, + callRecordingEnabled: true, + callTranscriptionEnabled: true, + callAnnouncementLanguage: "fr", + callRecordingPurpose: "Support quality review", + }) + expect(result.recording).toEqual({ + status: "ENABLED", + purpose: "Support quality review", + announcementLanguage: "fr", + }) + expect(result.transcription).toEqual(result.recording) + }) + + test("falls back to the contact locale when callAnnouncementLanguage is not configured", () => { + const result = buildCallAnnouncementOptions( + { ...baseIntegration, callRecordingEnabled: true }, + "es", + ) + expect(result.recording?.announcementLanguage).toBe("es") + }) + + test("prefers the integration's configured announcement language over the contact locale", () => { + const result = buildCallAnnouncementOptions( + { + ...baseIntegration, + callRecordingEnabled: true, + callAnnouncementLanguage: "vi", + }, + "es", + ) + expect(result.recording?.announcementLanguage).toBe("vi") + }) + + test("resolves an unsupported contact locale to the en_US fallback", () => { + const result = buildCallAnnouncementOptions( + { ...baseIntegration, callRecordingEnabled: true }, + "zz_ZZ", + ) + expect(result.recording?.announcementLanguage).toBe("en_US") + }) + + test("falls back to the default purpose when callRecordingPurpose is null", () => { + const result = buildCallAnnouncementOptions({ + ...baseIntegration, + callRecordingEnabled: true, + callRecordingPurpose: null, + }) + expect(result.recording?.purpose).toBe(DEFAULT_CALL_ANNOUNCEMENT_PURPOSE) + }) +}) + +describe("hasCallAnnouncementOptions", () => { + test("is false when both are undefined", () => { + expect(hasCallAnnouncementOptions({})).toBe(false) + }) + + test("is true when either recording or transcription is set", () => { + const announcement = { + status: "ENABLED" as const, + purpose: "p", + announcementLanguage: "en_US", + } + expect(hasCallAnnouncementOptions({ recording: announcement })).toBe(true) + expect(hasCallAnnouncementOptions({ transcription: announcement })).toBe( + true, + ) + }) +}) + +describe("isCallAnnouncementValidationError", () => { + class FakeWhatsappException extends Error { + httpStatusCode: number + constructor(httpStatusCode: number) { + super("Meta rejected the call") + this.httpStatusCode = httpStatusCode + } + } + + test("is true for a WhatsappException-shaped 4xx", async () => { + const { WhatsappException } = await import( + "@chatbotx.io/integration-whatsapp/exception" + ) + const error = new WhatsappException("bad purpose", 400) + expect(isCallAnnouncementValidationError(error)).toBe(true) + }) + + test("is false for a WhatsappException-shaped 5xx", async () => { + const { WhatsappException } = await import( + "@chatbotx.io/integration-whatsapp/exception" + ) + const error = new WhatsappException("Meta is down", 502) + expect(isCallAnnouncementValidationError(error)).toBe(false) + }) + + test("is false for a plain (non-WhatsappException) error, even with an httpStatusCode field", () => { + expect( + isCallAnnouncementValidationError(new FakeWhatsappException(400)), + ).toBe(false) + }) + + test("is false for a non-Error value", () => { + expect(isCallAnnouncementValidationError("boom")).toBe(false) + }) + + test.each([ + 401, 403, 429, + ])("is false for a WhatsappException with httpStatusCode %s (auth/permission/rate-limit, never announcement-related)", async (httpStatusCode) => { + const { WhatsappException } = await import( + "@chatbotx.io/integration-whatsapp/exception" + ) + const error = new WhatsappException("rejected", httpStatusCode) + expect(isCallAnnouncementValidationError(error)).toBe(false) + }) + + test("is false for the local whatsappCallAnnouncementPurposeTooLong validation error", async () => { + const { WhatsappException } = await import( + "@chatbotx.io/integration-whatsapp/exception" + ) + const error = new WhatsappException( + "purpose too long", + 400, + "whatsappCallAnnouncementPurposeTooLong", + ) + expect(isCallAnnouncementValidationError(error)).toBe(false) + }) + + test("is still true for an unrelated 400 error (e.g. bad SDP), so it retries once", async () => { + const { WhatsappException } = await import( + "@chatbotx.io/integration-whatsapp/exception" + ) + const error = new WhatsappException("bad sdp", 400, "invalidParameter") + expect(isCallAnnouncementValidationError(error)).toBe(true) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-call-announcement-validation-error.test.ts b/apps/builder/__tests__/whatsapp-call-announcement-validation-error.test.ts new file mode 100644 index 0000000000..b9bf8d63f0 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-call-announcement-validation-error.test.ts @@ -0,0 +1,53 @@ +import { WhatsappException } from "@chatbotx.io/integration-whatsapp/exception" +import { describe, expect, test } from "vitest" +import { isCallAnnouncementValidationError } from "@/features/integration-whatsapp/calling/actions/call-announcement-options" + +const metaError = (httpStatusCode: number, code: number | string) => + new WhatsappException("(#error) meta rejected the call", httpStatusCode, code) + +describe("isCallAnnouncementValidationError", () => { + test("treats an undocumented Meta 4xx (e.g. invalid parameter) as a possible announcement rejection", () => { + expect(isCallAnnouncementValidationError(metaError(400, 100))).toBe(true) + }) + + test.each([ + [138_003, "duplicate call"], + [138_012, "business-initiated calls limit"], + [138_013, "business-initiated calling unavailable"], + [138_014, "calling temporarily disabled"], + [138_001, "receiver uncallable"], + [131_044, "no valid payment method"], + [138_019, "call setup failed"], + ])("never retries a documented calling error %i (%s)", (code) => { + expect(isCallAnnouncementValidationError(metaError(400, code))).toBe(false) + }) + + test("matches a documented code delivered as a string", () => { + expect(isCallAnnouncementValidationError(metaError(400, "138003"))).toBe( + false, + ) + }) + + test.each([ + 401, 403, 429, + ])("never retries an auth or rate-limit status %i", (httpStatusCode) => { + expect( + isCallAnnouncementValidationError(metaError(httpStatusCode, 100)), + ).toBe(false) + }) + + test("never retries the local purpose-too-long validation error", () => { + expect( + isCallAnnouncementValidationError( + metaError(400, "whatsappCallAnnouncementPurposeTooLong"), + ), + ).toBe(false) + }) + + test.each([ + ["a 5xx", metaError(500, 138_004)], + ["a non-WhatsApp error", new Error("boom")], + ])("never retries %s", (_, error) => { + expect(isCallAnnouncementValidationError(error)).toBe(false) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-call-card.test.tsx b/apps/builder/__tests__/whatsapp-call-card.test.tsx new file mode 100644 index 0000000000..dcb81984a7 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-call-card.test.tsx @@ -0,0 +1,387 @@ +import type { MessageWhatsappCallEntity } from "@chatbotx.io/sdk" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +/** Echoes the key back so assertions never depend on the English copy. */ +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})) + +vi.mock("@/hooks/routing", () => ({ + useWorkspaceId: () => "ws-1", +})) + +vi.mock("@/features/chat/store/chat-store-provider", () => ({ + useChatStore: ( + selector: (state: { + conversations: unknown[] + activeConversationId: string | null + }) => unknown, + ) => selector({ conversations: [], activeConversationId: null }), +})) + +const { getCallRecordingUrlActionMock } = vi.hoisted(() => ({ + getCallRecordingUrlActionMock: vi.fn(), +})) + +vi.mock("@/features/messages/actions/get-call-recording-url.action", () => ({ + getCallRecordingUrlAction: getCallRecordingUrlActionMock, +})) + +const { WhatsappCallCard } = await import( + "@/features/messages/components/whatsapp-call-card" +) +const { useCallInfoSheetStore } = await import( + "@/features/messages/store/call-info-sheet-store" +) + +let container: HTMLDivElement | null = null +let root: Root | null = null + +function renderComponent(ui: React.ReactElement) { + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render(ui) + }) + return container +} + +afterEach(() => { + if (root) { + act(() => { + root?.unmount() + }) + } + container?.remove() + container = null + root = null +}) + +const baseCall: MessageWhatsappCallEntity = { + type: "whatsapp_call", + direction: "userInitiated", + status: "completed", + durationSeconds: 12, + answerSeconds: 15, + callId: "call-1", + hasRecording: true, + recordingRequested: true, + transcriptionRequested: false, + hasTranscript: false, + hasSummary: false, + recordingExpired: false, +} + +describe("WhatsappCallCard", () => { + beforeEach(() => { + vi.clearAllMocks() + getCallRecordingUrlActionMock.mockResolvedValue({ + data: { url: "https://signed.example/audio.ogg" }, + }) + useCallInfoSheetStore.setState({ + isOpen: false, + whatsappCallId: null, + tab: "transcript", + }) + }) + + test("a failed inbound call renders as missed", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("missedVoiceCall") + expect(el.querySelector("button")).toBeNull() + }) + + test("a failed outbound call renders as 'no answer', never 'missed'", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("unansweredVoiceCall") + expect(el.textContent).not.toContain("missedVoiceCall") + expect(el.querySelector("button")).toBeNull() + }) + + test("an agent-cancelled outbound call renders as 'cancelled', never 'no answer'", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("canceledVoiceCall") + expect(el.textContent).not.toContain("unansweredVoiceCall") + expect(el.querySelector("button")).toBeNull() + }) + + test("declined call renders no player and no action buttons", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("declinedVoiceCall") + expect(el.querySelector("button")).toBeNull() + }) + + test("header shows the answer wait (ring time) in human units, while the player shows talk duration", () => { + const el = renderComponent() + expect(el.textContent).toContain("audioCall") + // Header sub-label = answerSeconds (15 → "15s"), NOT the m:ss talk time. + expect(el.textContent).toContain("15s") + // Player total = durationSeconds (talk time, 12 → 0:12). + expect(el.textContent).toContain("0:00 / 0:12") + }) + + test("header renders a multi-minute answer wait as '1m 30s', not raw seconds", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("1m 30s") + expect(el.textContent).not.toContain("90s") + }) + + test("header omits the sub-label when answerSeconds is unknown", () => { + const { answerSeconds: _omit, ...withoutAnswer } = baseCall + const el = renderComponent() + expect(el.textContent).toContain("audioCall") + // Never falls back to any duration in the header. + expect(el.textContent).not.toContain("15s") + }) + + test("recordingExpired shows the unavailable message instead of the player", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("recordingUnavailable") + }) + + test("shows a processing placeholder instead of the player before hasRecording is true", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("recordingProcessing") + expect(el.querySelector('button[aria-label="play"]')).toBeNull() + }) + + test("stops showing 'processing' once the grace window after the call has passed", () => { + const twentyMinutesAgo = new Date(Date.now() - 20 * 60 * 1000) + const el = renderComponent( + , + ) + // A recording that never arrived must not leave the card stuck. + expect(el.textContent).not.toContain("recordingProcessing") + expect(el.querySelector('button[aria-label="play"]')).toBeNull() + expect(el.textContent).toContain("audioCall") + }) + + test("shows no player row (and no endless 'processing') when recording was never requested", () => { + const el = renderComponent( + , + ) + // A call with recording off never gets one — the placeholder that can + // never resolve must not render. + expect(el.textContent).not.toContain("recordingProcessing") + expect(el.querySelector('button[aria-label="play"]')).toBeNull() + // The call header itself still renders. + expect(el.textContent).toContain("audioCall") + }) + + test("renders the player once hasRecording is true", () => { + const el = renderComponent( + , + ) + expect(el.querySelector('button[aria-label="play"]')).not.toBeNull() + expect(el.textContent).not.toContain("recordingProcessing") + }) + + test("transcript button is still shown (disabled) even when transcription was never requested", () => { + // Matches the reference UI: both Transcript and AI Summary are always + // visible; Transcript is merely disabled (with a tooltip) when there is + // no transcript, rather than hidden. + const el = renderComponent( + , + ) + expect(el.textContent).toContain("transcript") + expect(el.querySelector("button:disabled")).not.toBeNull() + }) + + test("transcript button renders disabled until hasTranscript is true", () => { + const el = renderComponent( + , + ) + const buttons = Array.from(el.querySelectorAll("button")) + const transcriptButton = buttons.find((button) => + button.textContent?.includes("transcript"), + ) + expect(transcriptButton).toBeDefined() + expect(transcriptButton?.disabled).toBe(true) + }) + + test("AI summary button stays disabled when there is no transcript, even if a summary somehow exists", () => { + const el = renderComponent( + , + ) + const buttons = Array.from(el.querySelectorAll("button")) + const summaryButton = buttons.find((button) => + button.textContent?.includes("aiSummary"), + ) + expect(summaryButton).toBeDefined() + expect(summaryButton?.disabled).toBe(true) + }) + + test("AI summary button is enabled as soon as a transcript exists — the summary itself is generated on demand from the sheet", () => { + const el = renderComponent( + , + ) + const buttons = Array.from(el.querySelectorAll("button")) + const summaryButton = buttons.find((button) => + button.textContent?.includes("aiSummary"), + ) + expect(summaryButton).toBeDefined() + expect(summaryButton?.disabled).toBe(false) + + act(() => { + summaryButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + + expect(useCallInfoSheetStore.getState()).toMatchObject({ + isOpen: true, + whatsappCallId: "call-1", + tab: "summary", + }) + }) + + test("enabled transcript button opens the sheet with the transcript tab", () => { + const el = renderComponent( + , + ) + const buttons = Array.from(el.querySelectorAll("button")) + const transcriptButton = buttons.find((button) => + button.textContent?.includes("transcript"), + ) + + act(() => { + transcriptButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ) + }) + + expect(useCallInfoSheetStore.getState()).toMatchObject({ + isOpen: true, + whatsappCallId: "call-1", + tab: "transcript", + }) + }) + + test("shows the contact name when provided", () => { + const el = renderComponent( + , + ) + expect(el.textContent).toContain("Jane Doe") + }) + + test("clicking play lazily requests a signed URL via getCallRecordingUrlAction", async () => { + const el = renderComponent() + const playButton = el.querySelector('button[aria-label="play"]') + expect(playButton).not.toBeNull() + + await act(async () => { + playButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(getCallRecordingUrlActionMock).toHaveBeenCalledWith("ws-1", { + whatsappCallId: "call-1", + }) + }) + + test("Download triggers a real download instead of opening the recording inline", async () => { + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => { + // jsdom does not implement anchor clicks; only the call matters here. + }) + + const el = renderComponent() + const moreOptionsButton = el.querySelector( + 'button[aria-label="moreOptions"]', + ) + expect(moreOptionsButton).not.toBeNull() + + act(() => { + moreOptionsButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ) + }) + + const downloadItem = Array.from( + document.body.querySelectorAll('[role="menuitem"]'), + ).find((item) => item.textContent?.includes("download")) + expect(downloadItem).toBeDefined() + + await act(async () => { + downloadItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(getCallRecordingUrlActionMock).toHaveBeenCalledWith("ws-1", { + whatsappCallId: "call-1", + }) + expect(clickSpy).toHaveBeenCalled() + const createdLink = clickSpy.mock.instances.at(-1) as HTMLAnchorElement + expect(createdLink.href).toBe("https://signed.example/audio.ogg") + expect(createdLink.getAttribute("download")).toBe("") + + clickSpy.mockRestore() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-call-info-sheet.test.tsx b/apps/builder/__tests__/whatsapp-call-info-sheet.test.tsx new file mode 100644 index 0000000000..186cdcfe07 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-call-info-sheet.test.tsx @@ -0,0 +1,276 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("@/hooks/routing", () => ({ + useWorkspaceId: () => "ws-1", +})) + +const { + getCallTranscriptActionMock, + getCallSummaryActionMock, + getCallRecordingUrlActionMock, + listCallSummaryProvidersActionMock, +} = vi.hoisted(() => ({ + getCallTranscriptActionMock: vi.fn(), + getCallSummaryActionMock: vi.fn(), + getCallRecordingUrlActionMock: vi.fn(), + listCallSummaryProvidersActionMock: vi.fn(), +})) + +vi.mock("@/features/messages/actions/get-call-transcript.action", () => ({ + getCallTranscriptAction: getCallTranscriptActionMock, +})) +vi.mock("@/features/messages/actions/get-call-summary.action", () => ({ + getCallSummaryAction: getCallSummaryActionMock, +})) +vi.mock("@/features/messages/actions/get-call-recording-url.action", () => ({ + getCallRecordingUrlAction: getCallRecordingUrlActionMock, +})) +vi.mock( + "@/features/messages/actions/list-call-summary-providers.action", + () => ({ + listCallSummaryProvidersAction: listCallSummaryProvidersActionMock, + }), +) +vi.mock("@/features/messages/actions/generate-call-ai-summary.action", () => ({ + generateCallAiSummaryAction: Object.assign(vi.fn(), { + bind: () => vi.fn(), + }), +})) + +const { WhatsappCallInfoSheet } = await import( + "@/features/messages/components/whatsapp-call-info-sheet" +) +const { useCallInfoSheetStore } = await import( + "@/features/messages/store/call-info-sheet-store" +) +const { useCallPlaybackStore } = await import( + "@/features/messages/store/call-playback-store" +) + +let container: HTMLDivElement | null = null +let root: Root | null = null + +function renderSheet() { + const queryClient = new QueryClient({ + // Default retries would delay an errored query well past this test + // file's `flush()` window before `isError` ever flips to true. + defaultOptions: { queries: { retry: false } }, + }) + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render( + + + , + ) + }) +} + +const flush = async () => { + for (let i = 0; i < 5; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + } +} + +afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + container = null + root = null +}) + +const speakerTranscript = { + segments: [ + { speaker: "Business", start: 0, end: 2, text: "Hello, how can I help?" }, + { + speaker: "Customer", + start: 2, + end: 5, + text: "I have a billing question.", + }, + ], + speakerNames: { business: "Agent Smith", customer: "Ada Lovelace" }, + hasSpeakers: true, +} + +describe("WhatsappCallInfoSheet", () => { + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + useCallInfoSheetStore.setState({ + isOpen: false, + whatsappCallId: null, + tab: "transcript", + }) + useCallPlaybackStore.setState({ + callId: null, + status: "idle", + currentTime: 0, + duration: 0, + volume: 1, + }) + getCallTranscriptActionMock.mockResolvedValue({ data: speakerTranscript }) + getCallSummaryActionMock.mockResolvedValue({ data: { result: undefined } }) + listCallSummaryProvidersActionMock.mockResolvedValue({ + data: { providers: [] }, + }) + getCallRecordingUrlActionMock.mockResolvedValue({ + data: { url: "https://signed.example/audio.ogg" }, + }) + }) + + test("renders nothing when the store is closed", async () => { + renderSheet() + await flush() + expect(document.body.textContent).not.toContain("transcriptTab") + }) + + test("opens on the transcript tab when the store says so", async () => { + act(() => { + useCallInfoSheetStore.getState().open({ + whatsappCallId: "call-1", + tab: "transcript", + }) + }) + renderSheet() + await flush() + + expect(getCallTranscriptActionMock).toHaveBeenCalledWith("ws-1", { + whatsappCallId: "call-1", + }) + expect(document.body.textContent).toContain("Hello, how can I help?") + expect(document.body.textContent).toContain("Agent Smith") + expect(document.body.textContent).toContain("Ada Lovelace") + }) + + test("opens on the summary tab when the store says so", async () => { + act(() => { + useCallInfoSheetStore.getState().open({ + whatsappCallId: "call-1", + tab: "summary", + }) + }) + renderSheet() + await flush() + + // The summary tab's empty state renders its own "generateSummary" copy, + // proving the AI Summary panel (not the transcript panel) is active. + expect(document.body.textContent).toContain("generateSummary") + }) + + test("search filters the transcript segments", async () => { + act(() => { + useCallInfoSheetStore.getState().open({ + whatsappCallId: "call-1", + tab: "transcript", + }) + }) + renderSheet() + await flush() + + const search = document.body.querySelector( + "input[type=text], input:not([type])", + ) as HTMLInputElement + expect(search).not.toBeNull() + + const nativeValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set + act(() => { + nativeValueSetter?.call(search, "billing") + search.dispatchEvent(new Event("input", { bubbles: true })) + }) + await flush() + + expect(document.body.textContent).toContain("I have a billing question.") + expect(document.body.textContent).not.toContain("Hello, how can I help?") + }) + + test("clicking a segment seeks the shared playback store to its start time", async () => { + act(() => { + useCallInfoSheetStore.getState().open({ + whatsappCallId: "call-1", + tab: "transcript", + }) + }) + renderSheet() + await flush() + + const rows = Array.from(document.body.querySelectorAll("button")).filter( + (button) => button.textContent?.includes("billing question"), + ) + expect(rows).toHaveLength(1) + + await act(async () => { + rows[0].dispatchEvent(new MouseEvent("click", { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(useCallPlaybackStore.getState().currentTime).toBe(2) + }) + + test("empty segments render the transcript-unavailable state", async () => { + getCallTranscriptActionMock.mockResolvedValue({ + data: { + segments: [], + speakerNames: { business: "", customer: "" }, + hasSpeakers: false, + }, + }) + act(() => { + useCallInfoSheetStore.getState().open({ + whatsappCallId: "call-1", + tab: "transcript", + }) + }) + renderSheet() + await flush() + + expect(document.body.textContent).toContain("transcriptUnavailable") + }) + + test("a transcript query error renders a retry state, not the language-unavailable copy", async () => { + getCallTranscriptActionMock.mockRejectedValue(new Error("network down")) + act(() => { + useCallInfoSheetStore.getState().open({ + whatsappCallId: "call-1", + tab: "transcript", + }) + }) + renderSheet() + await flush() + + expect(document.body.textContent).toContain("transcriptError") + expect(document.body.textContent).not.toContain("transcriptUnavailable") + + const retryButton = Array.from( + document.body.querySelectorAll("button"), + ).find((button) => button.textContent?.includes("retry")) + expect(retryButton).toBeDefined() + + getCallTranscriptActionMock.mockResolvedValue({ data: speakerTranscript }) + act(() => { + retryButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + await flush() + + expect(document.body.textContent).toContain("Hello, how can I help?") + }) +}) diff --git a/apps/builder/__tests__/whatsapp-call-panel.test.tsx b/apps/builder/__tests__/whatsapp-call-panel.test.tsx new file mode 100644 index 0000000000..e41976f678 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-call-panel.test.tsx @@ -0,0 +1,381 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { + useWhatsappVoipCallStore, + WhatsappVoipCallPhase, +} from "@/features/integration-whatsapp/calling/voip/voip-call-store" +import { WhatsappCallPanel } from "@/features/integration-whatsapp/calling/voip/whatsapp-call-panel" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key}:${JSON.stringify(values)}` : key, +})) + +const contextMock = { + answer: vi.fn(), + dismiss: vi.fn(), + hangup: vi.fn(), + toggleMute: vi.fn(), + dismissEnded: vi.fn(), + startOutbound: vi.fn(), +} +vi.mock( + "@/features/integration-whatsapp/calling/voip/whatsapp-voip-call-context", + () => ({ + useWhatsappVoipCallContext: () => contextMock, + }), +) + +const preparingCall = { + transport: "voip" as const, + whatsappCallId: "nonce-1", + wacid: "", + phase: WhatsappVoipCallPhase.preparing, + direction: "outbound" as const, + conversationId: "conversation-1", + contactInboxId: "contact-inbox-1", + contactName: "Ada Lovelace", + attemptId: "nonce-1", + deadlineAt: new Date().toISOString(), + isMuted: false, + isRecording: false, +} + +const incomingCall = { + transport: "voip" as const, + whatsappCallId: "call-1", + wacid: "wacid-1", + phase: WhatsappVoipCallPhase.incomingRinging, + direction: "inbound" as const, + conversationId: "conversation-1", + contactInboxId: "contact-inbox-1", + contactName: "Ada Lovelace", + offer: { sdpType: "offer" as const, sdp: "v=0" }, + deadlineAt: new Date(Date.now() + 30_000).toISOString(), + isMuted: false, + isRecording: false, +} + +const activeCall = { + transport: "voip" as const, + whatsappCallId: "call-1", + wacid: "wacid-1", + phase: WhatsappVoipCallPhase.active, + direction: "inbound" as const, + conversationId: "conversation-1", + contactInboxId: "contact-inbox-1", + contactName: "Ada Lovelace", + deadlineAt: new Date().toISOString(), + isMuted: false, + isRecording: true, + startedAt: Date.now(), +} + +const endedCall = { + ...activeCall, + phase: WhatsappVoipCallPhase.ended, + endedStatus: "rejected" as const, +} + +const connectionLostCall = { + ...activeCall, + phase: WhatsappVoipCallPhase.ended, + endedStatus: "connectionLost" as const, +} + +const outboundNoAnswerCall = { + transport: "voip" as const, + whatsappCallId: "out-call-1", + wacid: "out-wacid-1", + phase: WhatsappVoipCallPhase.ended, + direction: "outbound" as const, + conversationId: "conversation-1", + contactInboxId: "contact-inbox-1", + contactName: "Ada Lovelace", + attemptId: "attempt-1", + deadlineAt: new Date().toISOString(), + isMuted: false, + isRecording: false, + endedStatus: "completed" as const, + // never reached active — no startedAt. +} + +const outboundEndedWithDurationCall = { + ...outboundNoAnswerCall, + startedAt: Date.now() - 12_000, +} + +describe("WhatsappCallPanel", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + useWhatsappVoipCallStore.setState({ call: null }) + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + const render = () => + act(() => { + root.render() + }) + + test("renders nothing when there is no call", async () => { + await render() + expect(document.body.textContent).toBe("") + }) + + test("preparing phase renders a round End button and no Answer/Reject", async () => { + useWhatsappVoipCallStore.setState({ call: preparingCall }) + await render() + + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.end"]`), + ).not.toBeNull() + expect( + document.querySelector(`[aria-label="whatsapp.calls.answer"]`), + ).toBeNull() + }) + + test("clicking End during preparing calls hangup()", async () => { + useWhatsappVoipCallStore.setState({ call: preparingCall }) + await render() + + const endButton = document.querySelector( + `[aria-label="whatsapp.calls.panel.end"]`, + ) + act(() => { + endButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + + expect(contextMock.hangup).toHaveBeenCalledTimes(1) + }) + + test("incoming ringing renders Answer + Reject and a countdown, with a backdrop", async () => { + useWhatsappVoipCallStore.setState({ call: incomingCall }) + await render() + + expect( + document.querySelector(`[aria-label="whatsapp.calls.answer"]`), + ).not.toBeNull() + expect( + document.querySelector(`[aria-label="whatsapp.calls.reject"]`), + ).not.toBeNull() + expect(document.body.textContent).toContain("ringingCountdown") + }) + + test("clicking Answer calls answer(), clicking Reject calls dismiss()", async () => { + useWhatsappVoipCallStore.setState({ call: incomingCall }) + await render() + + act(() => { + document + .querySelector(`[aria-label="whatsapp.calls.answer"]`) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect(contextMock.answer).toHaveBeenCalledTimes(1) + + act(() => { + document + .querySelector(`[aria-label="whatsapp.calls.reject"]`) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect(contextMock.dismiss).toHaveBeenCalledTimes(1) + }) + + test("active phase renders a live timer, mute, end, and the recording indicator", async () => { + useWhatsappVoipCallStore.setState({ call: activeCall }) + await render() + + expect( + document.querySelector(`[aria-label="whatsapp.calls.card.mute"]`), + ).not.toBeNull() + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.end"]`), + ).not.toBeNull() + expect(document.body.textContent).toContain( + "whatsapp.calls.recordingInProgress", + ) + }) + + test("clicking End while active calls hangup(), clicking Mute calls toggleMute()", async () => { + useWhatsappVoipCallStore.setState({ call: activeCall }) + await render() + + act(() => { + document + .querySelector(`[aria-label="whatsapp.calls.card.mute"]`) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect(contextMock.toggleMute).toHaveBeenCalledTimes(1) + + act(() => { + document + .querySelector(`[aria-label="whatsapp.calls.panel.end"]`) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect(contextMock.hangup).toHaveBeenCalledTimes(1) + }) + + test("ended phase shows the mapped message and a dismiss control, no Answer/End", async () => { + useWhatsappVoipCallStore.setState({ call: endedCall }) + await render() + + expect(document.body.textContent).toContain( + "whatsapp.calls.panel.statusDeclined", + ) + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.end"]`), + ).toBeNull() + expect( + document.querySelector(`[aria-label="whatsapp.calls.answer"]`), + ).toBeNull() + }) + + test("R5: a connection-lost ended call shows the translated connection-lost notice", async () => { + useWhatsappVoipCallStore.setState({ call: connectionLostCall }) + await render() + + expect(document.body.textContent).toContain( + "whatsapp.calls.panel.statusConnectionLost", + ) + }) + + test("clicking dismiss on the ended message calls dismissEnded()", async () => { + useWhatsappVoipCallStore.setState({ call: endedCall }) + await render() + + const dismissButton = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.includes("whatsapp.calls.panel.dismiss"), + ) + act(() => { + dismissButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + + expect(contextMock.dismissEnded).toHaveBeenCalledTimes(1) + }) + + test("minimize collapses the panel to a small pill; clicking it re-expands the same panel", async () => { + useWhatsappVoipCallStore.setState({ call: activeCall }) + await render() + + act(() => { + document + .querySelector(`[aria-label="whatsapp.calls.panel.minimize"]`) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.end"]`), + ).toBeNull() + const pill = document.querySelector( + `[aria-label="whatsapp.calls.panel.expand"]`, + ) + expect(pill).not.toBeNull() + + act(() => { + pill?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.end"]`), + ).not.toBeNull() + }) + + test("an incoming ring always renders full even if the panel was left minimized from a previous call", async () => { + useWhatsappVoipCallStore.setState({ call: activeCall }) + await render() + + act(() => { + document + .querySelector(`[aria-label="whatsapp.calls.panel.minimize"]`) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.expand"]`), + ).not.toBeNull() + + // A DIFFERENT call (a fresh inbound ring) takes the slot. + act(() => { + useWhatsappVoipCallStore.setState({ call: incomingCall }) + }) + await render() + + expect( + document.querySelector(`[aria-label="whatsapp.calls.answer"]`), + ).not.toBeNull() + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.expand"]`), + ).toBeNull() + }) + + test("isMinimized resets when a different call takes the slot even outside incomingRinging", async () => { + useWhatsappVoipCallStore.setState({ call: activeCall }) + await render() + + act(() => { + document + .querySelector(`[aria-label="whatsapp.calls.panel.minimize"]`) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.expand"]`), + ).not.toBeNull() + + act(() => { + useWhatsappVoipCallStore.setState({ + call: { ...activeCall, whatsappCallId: "call-2" }, + }) + }) + await render() + + // Fresh call id -> re-expanded, showing the full active panel again. + expect( + document.querySelector(`[aria-label="whatsapp.calls.panel.end"]`), + ).not.toBeNull() + }) + + test("an outbound call that never connected shows 'No answer' with no duration", async () => { + useWhatsappVoipCallStore.setState({ call: outboundNoAnswerCall }) + await render() + + expect(document.body.textContent).toContain( + "whatsapp.calls.panel.statusNoAnswer", + ) + expect(document.body.textContent).not.toContain("·") + }) + + test("an outbound call that connected then ended shows 'Call ended · mm:ss'", async () => { + useWhatsappVoipCallStore.setState({ call: outboundEndedWithDurationCall }) + await render() + + expect(document.body.textContent).toContain( + "whatsapp.calls.panel.statusCallEnded", + ) + expect(document.body.textContent).toContain("·") + expect(document.body.textContent).not.toContain( + "whatsapp.calls.panel.statusNoAnswer", + ) + }) + + test("the eyebrow reads CALL ENDED (not ON CALL) for any ended phase", async () => { + useWhatsappVoipCallStore.setState({ call: endedCall }) + await render() + + expect(document.body.textContent).toContain( + "whatsapp.calls.panel.eyebrowCallEnded", + ) + expect(document.body.textContent).not.toContain( + "whatsapp.calls.panel.eyebrowOnCall", + ) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-call-recorder.test.ts b/apps/builder/__tests__/whatsapp-call-recorder.test.ts new file mode 100644 index 0000000000..9e063c299f --- /dev/null +++ b/apps/builder/__tests__/whatsapp-call-recorder.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { startCallRecorder } from "@/features/integration-whatsapp/calling/voip/call-recorder" + +vi.mock("@/lib/log", () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})) + +type MockTrack = { kind: string } +const makeStream = (label: string): MediaStream => + ({ + getTracks: () => [{ kind: "audio" }] as MockTrack[], + id: label, + }) as unknown as MediaStream + +const createdSources: Array<{ + stream: MediaStream + connect: ReturnType +}> = [] +const destinationStream = { id: "mixed-destination" } as unknown as MediaStream +let audioContextCloseMock: ReturnType + +class MockAudioContext { + createMediaStreamSource = vi.fn((stream: MediaStream) => { + const source = { stream, connect: vi.fn() } + createdSources.push(source) + return source + }) + createMediaStreamDestination = vi.fn(() => ({ stream: destinationStream })) + close = audioContextCloseMock +} + +let mediaRecorderInstances: MockMediaRecorder[] = [] + +class MockMediaRecorder { + static isTypeSupportedMock = vi.fn().mockReturnValue(true) + static isTypeSupported(mimeType: string): boolean { + return MockMediaRecorder.isTypeSupportedMock(mimeType) + } + + state: "inactive" | "recording" = "inactive" + mimeType: string + ondataavailable: ((event: { data: Blob }) => void) | null = null + onstop: (() => void) | null = null + start = vi.fn(() => { + this.state = "recording" + }) + stop = vi.fn(() => { + this.state = "inactive" + this.ondataavailable?.({ data: new Blob(["chunk"]) }) + this.onstop?.() + }) + + constructor(_stream: MediaStream, options: { mimeType: string }) { + this.mimeType = options.mimeType + mediaRecorderInstances.push(this) + } +} + +beforeEach(() => { + createdSources.length = 0 + mediaRecorderInstances = [] + audioContextCloseMock = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal("AudioContext", MockAudioContext) + vi.stubGlobal("MediaRecorder", MockMediaRecorder) + MockMediaRecorder.isTypeSupportedMock = vi.fn().mockReturnValue(true) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("startCallRecorder", () => { + test("mixes the local and remote streams into one destination and starts recording", () => { + const localStream = makeStream("local") + const remoteStream = makeStream("remote") + + const recorder = startCallRecorder({ + whatsappCallId: "call-1", + localStream, + remoteStream, + upload: vi.fn().mockResolvedValue(undefined), + }) + + expect(recorder).not.toBeNull() + expect(createdSources).toHaveLength(2) + expect(createdSources[0]?.stream).toBe(localStream) + expect(createdSources[1]?.stream).toBe(remoteStream) + expect(createdSources[0]?.connect).toHaveBeenCalledWith({ + stream: destinationStream, + }) + expect(createdSources[1]?.connect).toHaveBeenCalledWith({ + stream: destinationStream, + }) + expect(mediaRecorderInstances).toHaveLength(1) + expect(mediaRecorderInstances[0]?.start).toHaveBeenCalled() + }) + + test("negotiates the first supported mime type from the priority list", () => { + MockMediaRecorder.isTypeSupportedMock = vi + .fn() + .mockImplementation((mimeType: string) => mimeType === "audio/mp4") + + startCallRecorder({ + whatsappCallId: "call-1", + localStream: makeStream("local"), + remoteStream: makeStream("remote"), + upload: vi.fn().mockResolvedValue(undefined), + }) + + expect(mediaRecorderInstances[0]?.mimeType).toBe("audio/mp4") + }) + + test("skips recording entirely (returns null, never throws) when no mime type is supported", () => { + MockMediaRecorder.isTypeSupportedMock = vi.fn().mockReturnValue(false) + + const recorder = startCallRecorder({ + whatsappCallId: "call-1", + localStream: makeStream("local"), + remoteStream: makeStream("remote"), + upload: vi.fn().mockResolvedValue(undefined), + }) + + expect(recorder).toBeNull() + expect(mediaRecorderInstances).toHaveLength(0) + }) + + test("stop() flushes the final chunk and uploads the assembled blob with the right fields", async () => { + const upload = vi.fn().mockResolvedValue(undefined) + const recorder = startCallRecorder({ + whatsappCallId: "call-42", + localStream: makeStream("local"), + remoteStream: makeStream("remote"), + upload, + }) + + recorder?.stop() + // The upload runs inside `onstop`, asynchronously — flush microtasks. + await Promise.resolve() + await Promise.resolve() + + expect(upload).toHaveBeenCalledTimes(1) + const call = upload.mock.calls[0]?.[0] + expect(call.whatsappCallId).toBe("call-42") + expect(call.contentType).toBe("audio/webm") + expect(call.blob).toBeInstanceOf(Blob) + expect(audioContextCloseMock).toHaveBeenCalled() + }) + + test("stop() is a no-op the second time it is called", () => { + const upload = vi.fn().mockResolvedValue(undefined) + const recorder = startCallRecorder({ + whatsappCallId: "call-1", + localStream: makeStream("local"), + remoteStream: makeStream("remote"), + upload, + }) + + recorder?.stop() + recorder?.stop() + + expect(mediaRecorderInstances[0]?.stop).toHaveBeenCalledTimes(1) + }) + + test("upload failure is swallowed (logged, never thrown) and still closes the AudioContext", async () => { + const upload = vi.fn().mockRejectedValue(new Error("network down")) + const recorder = startCallRecorder({ + whatsappCallId: "call-1", + localStream: makeStream("local"), + remoteStream: makeStream("remote"), + upload, + }) + + expect(() => recorder?.stop()).not.toThrow() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(upload).toHaveBeenCalledTimes(1) + expect(audioContextCloseMock).toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-call-recording-upload-route.test.ts b/apps/builder/__tests__/whatsapp-call-recording-upload-route.test.ts new file mode 100644 index 0000000000..ea50bee954 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-call-recording-upload-route.test.ts @@ -0,0 +1,373 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + getCurrentUserId, + assertCurrentUserCanAccessChatbot, + findById, + findByInboxIdForWorkspace, + findWorkspaceById, + uploadRecording, + integrationQueueAdd, + loggerError, + checkWorkspaceOwnerAccess, +} = vi.hoisted(() => ({ + getCurrentUserId: vi.fn(), + assertCurrentUserCanAccessChatbot: vi.fn(), + findById: vi.fn(), + findByInboxIdForWorkspace: vi.fn(), + findWorkspaceById: vi.fn(), + uploadRecording: vi.fn(), + integrationQueueAdd: vi.fn(), + loggerError: vi.fn(), + checkWorkspaceOwnerAccess: vi.fn(), +})) + +vi.mock("@chatbotx.io/business", async (importOriginal) => { + const original = + await importOriginal() + return { + ...original, + callRecordingService: { uploadRecording }, + workspaceService: { findById: findWorkspaceById }, + } +}) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error { + httpStatusCode = 403 + }, +})) + +vi.mock( + "@/lib/workspace/authorize-workspace-access", + async (importOriginal) => { + const original = + await importOriginal< + typeof import("@/lib/workspace/authorize-workspace-access") + >() + return { + ...original, + checkWorkspaceOwnerAccess, + } + }, +) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + whatsappCallRepository: { findById }, + integrationWhatsappRepository: { findByInboxIdForWorkspace }, +})) + +vi.mock("@chatbotx.io/worker-config", async (importOriginal) => { + const original = + await importOriginal() + return { + ...original, + integrationQueue: { add: integrationQueueAdd }, + } +}) + +vi.mock("@/lib/auth/utils", () => ({ + getCurrentUserId, + assertCurrentUserCanAccessChatbot, +})) + +vi.mock("@/lib/log", () => ({ + logger: { error: loggerError, warn: vi.fn(), info: vi.fn() }, +})) + +const { POST } = await import("../src/app/api/whatsapp-call-recording/route") + +const buildRequest = ( + fields: Record, + headers?: Record, +) => { + const formData = new FormData() + for (const [key, value] of Object.entries(fields)) { + formData.append(key, value) + } + const request = new Request("http://localhost/api/whatsapp-call-recording", { + method: "POST", + body: formData, + headers, + }) + return request as never +} + +const audioBlob = () => new Blob([new Uint8Array([1, 2, 3])]) + +const callRow = { + id: "call-1", + workspaceId: "ws-1", + inboxId: "inbox-1", + wacid: "wacid-1", + attemptId: null as string | null, + answeredByUserId: "user-1", +} + +describe("POST /api/whatsapp-call-recording", () => { + beforeEach(() => { + vi.clearAllMocks() + getCurrentUserId.mockResolvedValue("user-1") + assertCurrentUserCanAccessChatbot.mockResolvedValue(undefined) + findById.mockResolvedValue(callRow) + findWorkspaceById.mockResolvedValue({ + id: "ws-1", + ownerId: "owner-1", + scheduledDeletionAt: null, + }) + checkWorkspaceOwnerAccess.mockResolvedValue(null) + findByInboxIdForWorkspace.mockResolvedValue({ + id: "integration-1", + callRecordingEnabled: true, + callRecordingMode: "browserWhisper", + }) + uploadRecording.mockResolvedValue({ + recordingPath: "space/ws-1/calls/call-1.webm", + }) + }) + + test("rejects a cross-site request", async () => { + const response = await POST( + buildRequest( + { + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }, + { "sec-fetch-site": "cross-site" }, + ), + ) + + expect(response.status).toBe(403) + expect(uploadRecording).not.toHaveBeenCalled() + }) + + test("accepts a same-origin request", async () => { + const response = await POST( + buildRequest( + { + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }, + { "sec-fetch-site": "same-origin" }, + ), + ) + + expect(response.status).toBe(200) + expect(uploadRecording).toHaveBeenCalled() + }) + + test("rejects an unauthenticated caller", async () => { + getCurrentUserId.mockResolvedValue(null) + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(401) + expect(uploadRecording).not.toHaveBeenCalled() + }) + + test("authorized happy path: uploads and enqueues the shared recording-ready job", async () => { + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(200) + expect(assertCurrentUserCanAccessChatbot).toHaveBeenCalledWith("ws-1") + expect(uploadRecording).toHaveBeenCalledWith({ + callId: "call-1", + workspaceId: "ws-1", + body: expect.any(Uint8Array), + contentType: "audio/webm", + }) + expect(integrationQueueAdd).toHaveBeenCalledWith( + "whatsappCallRecordingReady", + expect.objectContaining({ + type: "whatsappCallRecordingReady", + data: expect.objectContaining({ + callId: "call-1", + workspaceId: "ws-1", + recordingPath: "space/ws-1/calls/call-1.webm", + mimeType: "audio/webm", + correlationId: "wacid-1", + }), + }), + { jobId: "rec-ready-call-1" }, + ) + }) + + test("rejects when the caller is not the agent who answered the call", async () => { + findById.mockResolvedValue({ ...callRow, answeredByUserId: "other-user" }) + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(403) + expect(uploadRecording).not.toHaveBeenCalled() + expect(integrationQueueAdd).not.toHaveBeenCalled() + }) + + test("rejects when call recording is disabled on the integration", async () => { + findByInboxIdForWorkspace.mockResolvedValue({ + id: "integration-1", + callRecordingEnabled: false, + callRecordingMode: "browserWhisper", + }) + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(403) + expect(uploadRecording).not.toHaveBeenCalled() + }) + + test("rejects a browser upload when the integration's recording mode is metaNative", async () => { + findByInboxIdForWorkspace.mockResolvedValue({ + id: "integration-1", + callRecordingEnabled: true, + callRecordingMode: "metaNative", + }) + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(403) + expect(uploadRecording).not.toHaveBeenCalled() + }) + + test("rejects a cross-workspace membership check", async () => { + assertCurrentUserCanAccessChatbot.mockRejectedValue( + new (await import("@chatbotx.io/business/errors")).ChatbotXException( + "not a member", + ), + ) + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(403) + expect(uploadRecording).not.toHaveBeenCalled() + }) + + test("rejects a blocked owner (trial-expired) workspace and never enqueues the job", async () => { + checkWorkspaceOwnerAccess.mockResolvedValue("trialExpired") + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(403) + expect(checkWorkspaceOwnerAccess).toHaveBeenCalledWith({ + ownerId: "owner-1", + }) + expect(uploadRecording).not.toHaveBeenCalled() + expect(integrationQueueAdd).not.toHaveBeenCalled() + }) + + test("rejects a workspace scheduled for deletion and never enqueues the job", async () => { + findWorkspaceById.mockResolvedValue({ + id: "ws-1", + ownerId: "owner-1", + scheduledDeletionAt: new Date().toISOString(), + }) + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(403) + expect(uploadRecording).not.toHaveBeenCalled() + expect(integrationQueueAdd).not.toHaveBeenCalled() + }) + + test("rejects a disallowed mime type", async () => { + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "video/mp4", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(400) + expect(uploadRecording).not.toHaveBeenCalled() + }) + + test("rejects a missing call row", async () => { + findById.mockResolvedValue(undefined) + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(404) + expect(uploadRecording).not.toHaveBeenCalled() + }) + + test("rejects a missing integration", async () => { + findByInboxIdForWorkspace.mockResolvedValue(null) + + const response = await POST( + buildRequest({ + whatsappCallId: "call-1", + contentType: "audio/webm", + audio: audioBlob(), + }), + ) + + expect(response.status).toBe(404) + expect(uploadRecording).not.toHaveBeenCalled() + }) + + test("rejects a malformed request missing required fields", async () => { + const response = await POST( + buildRequest({ whatsappCallId: "call-1", contentType: "audio/webm" }), + ) + + expect(response.status).toBe(400) + expect(uploadRecording).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-calling-preflight-messaging-limit.test.ts b/apps/builder/__tests__/whatsapp-calling-preflight-messaging-limit.test.ts new file mode 100644 index 0000000000..098eaccf2a --- /dev/null +++ b/apps/builder/__tests__/whatsapp-calling-preflight-messaging-limit.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment node +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findPhoneNumberDetailMock, + getAppWebhookSubscriptionsMock, + loggerWarnMock, + resolveForOwnerMock, + resolveOwnerForWorkspaceMock, +} = vi.hoisted(() => ({ + findPhoneNumberDetailMock: vi.fn(), + getAppWebhookSubscriptionsMock: vi.fn(), + loggerWarnMock: vi.fn(), + resolveForOwnerMock: vi.fn(), + resolveOwnerForWorkspaceMock: vi.fn(), +})) + +vi.mock("server-only", () => ({})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: loggerWarnMock, error: vi.fn(), info: vi.fn() }, +})) + +vi.mock("@/lib/platform-credential-owner", () => ({ + resolveOwnerForWorkspace: resolveOwnerForWorkspaceMock, +})) + +vi.mock("@chatbotx.io/business", () => ({ + platformCredentialService: { resolveForOwner: resolveForOwnerMock }, +})) + +vi.mock("@chatbotx.io/integration-whatsapp/api/app-subscriptions", () => ({ + getAppWebhookSubscriptions: getAppWebhookSubscriptionsMock, + WHATSAPP_APP_SUBSCRIPTION_OBJECT: { + WHATSAPP_BUSINESS_ACCOUNT: "whatsapp_business_account", + }, + WHATSAPP_APP_WEBHOOK_FIELDS: { + CALLS: "calls", + ACCOUNT_SETTINGS_UPDATE: "account_settings_update", + }, +})) + +vi.mock("@chatbotx.io/integration-whatsapp/api/phone-number", () => ({ + findPhoneNumberDetail: findPhoneNumberDetailMock, +})) + +const { getWhatsappCallingPreflight } = await import( + "../src/features/integration-whatsapp/calling/get-whatsapp-calling-preflight" +) + +const AUTH = { + metadata: { isManual: false, phoneNumber: { id: "phone-1" } }, +} as unknown as Parameters[0]["auth"] +const WORKSPACE = { + id: "ws-1", + ownerId: "owner-1", +} as unknown as Parameters[0]["workspace"] + +describe("getWhatsappCallingPreflight — messaging limit (Meta error 138015)", () => { + beforeEach(() => { + vi.clearAllMocks() + resolveOwnerForWorkspaceMock.mockResolvedValue("owner-1") + resolveForOwnerMock.mockResolvedValue(undefined) + getAppWebhookSubscriptionsMock.mockResolvedValue([]) + }) + + test("reads whatsapp_business_manager_messaging_limit, not the deprecated messaging_limit_tier", async () => { + findPhoneNumberDetailMock.mockResolvedValue({ + platform_type: "CLOUD_API", + messaging_limit_tier: "TIER_100K", + whatsapp_business_manager_messaging_limit: "TIER_250", + }) + + const result = await getWhatsappCallingPreflight({ + workspace: WORKSPACE, + auth: AUTH, + }) + + expect(result.messagingLimitTier).toBe("TIER_250") + expect(result.messagingLimitSufficient).toBe(false) + }) + + test.each([ + "TIER_50", + "TIER_250", + "TIER_1K", + ])("%s is below Meta's '2000 or more' requirement", async (limit) => { + findPhoneNumberDetailMock.mockResolvedValue({ + whatsapp_business_manager_messaging_limit: limit, + }) + + const result = await getWhatsappCallingPreflight({ + workspace: WORKSPACE, + auth: AUTH, + }) + + expect(result.messagingLimitSufficient).toBe(false) + }) + + test.each([ + "TIER_2K", + "TIER_10K", + "TIER_100K", + "TIER_UNLIMITED", + ])("%s satisfies Meta's '2000 or more' requirement", async (limit) => { + findPhoneNumberDetailMock.mockResolvedValue({ + whatsapp_business_manager_messaging_limit: limit, + }) + + const result = await getWhatsappCallingPreflight({ + workspace: WORKSPACE, + auth: AUTH, + }) + + expect(result.messagingLimitSufficient).toBe(true) + expect(loggerWarnMock).not.toHaveBeenCalled() + }) + + test("a missing value counts as sufficient (never blocks on absence)", async () => { + findPhoneNumberDetailMock.mockResolvedValue({}) + + const result = await getWhatsappCallingPreflight({ + workspace: WORKSPACE, + auth: AUTH, + }) + + expect(result.messagingLimitTier).toBeNull() + expect(result.messagingLimitSufficient).toBe(true) + expect(loggerWarnMock).not.toHaveBeenCalled() + }) + + test("an unrecognized (e.g. future) tier counts as sufficient but is logged", async () => { + findPhoneNumberDetailMock.mockResolvedValue({ + whatsapp_business_manager_messaging_limit: "TIER_5M", + }) + + const result = await getWhatsappCallingPreflight({ + workspace: WORKSPACE, + auth: AUTH, + }) + + expect(result.messagingLimitSufficient).toBe(true) + expect(loggerWarnMock).toHaveBeenCalledWith( + { limit: "TIER_5M" }, + expect.stringContaining("Unrecognized"), + ) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-calling-preflight.test.tsx b/apps/builder/__tests__/whatsapp-calling-preflight.test.tsx new file mode 100644 index 0000000000..70e8549886 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-calling-preflight.test.tsx @@ -0,0 +1,203 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import type { WhatsappCallingPreflight } from "@/features/integration-whatsapp/calling/get-whatsapp-calling-preflight" +import { WhatsappCallsCard } from "@/features/integration-whatsapp/calling/whatsapp-calls-card" + +/** Echoes the key back so assertions never depend on the English copy. */ +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +const { toastErrorMock, toastSuccessMock, useActionMock, executeMock } = + vi.hoisted(() => ({ + toastErrorMock: vi.fn(), + toastSuccessMock: vi.fn(), + useActionMock: vi.fn(), + executeMock: vi.fn(), + })) + +vi.mock("sonner", () => ({ + toast: { error: toastErrorMock, success: toastSuccessMock }, +})) + +vi.mock("next-safe-action/hooks", () => ({ + useAction: useActionMock, +})) + +vi.mock( + "@/features/integration-whatsapp/calling/actions/update-calling-settings.action", + () => ({ + updateWhatsappCallingSettingsAction: { bind: () => vi.fn() }, + }), +) + +vi.mock( + "@/features/integration-whatsapp/calling/actions/fix-whatsapp-calls-subscription.action", + () => ({ + fixWhatsappCallsSubscriptionAction: { bind: () => vi.fn() }, + }), +) + +// jsdom ships no ResizeObserver, and Radix measures the switch thumb through it. +Object.assign(globalThis, { + ResizeObserver: class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, +}) + +const basePreflight: WhatsappCallingPreflight = { + isManual: false, + hasAppCredential: true, + callsSubscribed: true, + platformType: "CLOUD_API", + isCloudApiPlatform: true, + messagingLimitTier: "TIER_2K", + messagingLimitSufficient: true, +} + +describe("WhatsappCallsCard calling preflight", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + useActionMock.mockImplementation(() => ({ + execute: executeMock, + isPending: false, + })) + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + const render = (props: { + preflight?: WhatsappCallingPreflight | null + isSuperAdmin?: boolean + }) => + act(() => { + root.render( + , + ) + }) + + test("renders nothing extra when every check passes", async () => { + await render({ preflight: basePreflight }) + + expect(container.textContent).not.toContain( + "whatsapp.calls.preflight.title", + ) + }) + + test("shows the calls-not-subscribed notice when the app lacks the field", async () => { + await render({ + preflight: { ...basePreflight, callsSubscribed: false }, + }) + + expect(container.textContent).toContain( + "whatsapp.calls.preflight.callsNotSubscribed", + ) + }) + + test("shows the coexistence warning when the number is not Cloud API", async () => { + await render({ + preflight: { + ...basePreflight, + platformType: "NOT_APPLICABLE", + isCloudApiPlatform: false, + }, + }) + + expect(container.textContent).toContain( + "whatsapp.calls.preflight.coexistenceWarning", + ) + }) + + test("shows the messaging-limit-too-low notice when the limit is below 2000 (Meta error 138015)", async () => { + await render({ + preflight: { + ...basePreflight, + messagingLimitTier: "TIER_250", + messagingLimitSufficient: false, + }, + }) + + expect(container.textContent).toContain( + "whatsapp.calls.preflight.messagingLimitTooLow", + ) + }) + + test("shows the manual-integration notice instead of the checklist", async () => { + await render({ + preflight: { + ...basePreflight, + isManual: true, + hasAppCredential: false, + callsSubscribed: null, + }, + }) + + expect(container.textContent).toContain( + "whatsapp.calls.preflight.manualIntegrationNotice", + ) + expect(container.textContent).not.toContain( + "whatsapp.calls.preflight.callsNotSubscribed", + ) + }) + + test("shows the Fix button only for a super-admin when calls is not subscribed", async () => { + await render({ + preflight: { ...basePreflight, callsSubscribed: false }, + isSuperAdmin: true, + }) + + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => + candidate.textContent?.includes("whatsapp.calls.preflight.fixButton"), + ) + expect(button).toBeDefined() + }) + + test("hides the Fix button for a non-super-admin even when calls is not subscribed", async () => { + await render({ + preflight: { ...basePreflight, callsSubscribed: false }, + isSuperAdmin: false, + }) + + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => + candidate.textContent?.includes("whatsapp.calls.preflight.fixButton"), + ) + expect(button).toBeUndefined() + }) + + test("clicking Fix invokes the fix action", async () => { + await render({ + preflight: { ...basePreflight, callsSubscribed: false }, + isSuperAdmin: true, + }) + + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => + candidate.textContent?.includes("whatsapp.calls.preflight.fixButton"), + ) + act(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + }) + + expect(executeMock).toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-calling-settings-action.test.ts b/apps/builder/__tests__/whatsapp-calling-settings-action.test.ts new file mode 100644 index 0000000000..49c3c91d07 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-calling-settings-action.test.ts @@ -0,0 +1,154 @@ +// @vitest-environment node + +import { ChannelError, ChannelErrorCategory } from "@chatbotx.io/sdk" +import { beforeEach, describe, expect, test, vi } from "vitest" + +type UpdateCallingSettingsHandler = (args: { + bindArgsParsedInputs: readonly [string, string] + parsedInput: Record +}) => Promise + +const { findWorkspaceIntegrationMock, runActionMock, updateCallSettingsMock } = + vi.hoisted(() => ({ + findWorkspaceIntegrationMock: vi.fn(), + runActionMock: vi.fn(), + updateCallSettingsMock: vi.fn(), + })) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@/lib/auth/assert-workspace-super-admin", () => ({ + assertWorkspaceSuperAdmin: vi.fn(async () => undefined), +})) + +vi.mock("@chatbotx.io/business", () => ({ + buildContext: vi.fn(async () => ({})), + integrationWhatsappService: { + findWorkspaceIntegration: findWorkspaceIntegrationMock, + updateCallSettings: updateCallSettingsMock, + }, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error {}, +})) + +vi.mock("@chatbotx.io/integration-whatsapp/api/calling", () => ({ + getCallingSettings: vi.fn(), +})) + +vi.mock("@chatbotx.io/integration-whatsapp", () => ({ + mapToChannelError: (error: unknown) => error, + readWhatsappOriginErrorDetail: (originError: unknown) => { + const error = (originError as { error?: Record })?.error + return { + userTitle: error?.error_user_title, + userMessage: error?.error_user_msg, + } + }, +})) + +vi.mock("@/integration", () => ({ + integrations: { whatsapp: { runAction: runActionMock } }, +})) + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string) => key, +})) + +const { updateWhatsappCallingSettingsAction } = await import( + "../src/features/integration-whatsapp/calling/actions/update-calling-settings.action" +) +const action = + updateWhatsappCallingSettingsAction as unknown as UpdateCallingSettingsHandler + +const call = ( + parsedInput: Record, + integrationId = "integration-1", +) => + action({ + bindArgsParsedInputs: ["workspace-1", integrationId], + parsedInput, + }) + +const enableCalling = () => call({ status: "ENABLED" }) + +describe("updateWhatsappCallingSettingsAction", () => { + beforeEach(() => { + vi.clearAllMocks() + findWorkspaceIntegrationMock.mockResolvedValue({ + id: "integration-1", + auth: {}, + }) + updateCallSettingsMock.mockResolvedValue({}) + runActionMock.mockResolvedValue(undefined) + }) + + test("surfaces Meta's user-facing message when calling cannot be enabled", async () => { + runActionMock.mockRejectedValueOnce( + new ChannelError( + "Calling cannot be enabled", + ChannelErrorCategory.PAYLOAD_INVALID, + ).setOriginError({ + error: { + code: 138_015, + error_user_title: "Calling Cannot Be Enabled", + error_user_msg: + "Calling APIs cannot be enabled for this phone number.", + }, + }), + ) + + await expect(enableCalling()).rejects.toThrow( + "Calling APIs cannot be enabled for this phone number.", + ) + }) + + test("falls back to Meta's error message when no user-facing text exists", async () => { + runActionMock.mockRejectedValueOnce( + new ChannelError( + "(#141000) The phone number is not a valid Cloud API number", + ChannelErrorCategory.PAYLOAD_INVALID, + ), + ) + + await expect(enableCalling()).rejects.toThrow( + "(#141000) The phone number is not a valid Cloud API number", + ) + }) + + test("falls back to the translated message when Meta sent nothing readable", async () => { + runActionMock.mockRejectedValueOnce( + new ChannelError("", ChannelErrorCategory.UNKNOWN), + ) + + await expect(enableCalling()).rejects.toThrow( + "whatsapp.calls.errors.updateFailed", + ) + }) + + test("writes recording/retention/transcription settings locally without a Meta round-trip", async () => { + await call({ + recordingEnabled: true, + callRecordingRetentionDays: 30, + callTranscriptionEnabled: true, + }) + + expect(updateCallSettingsMock).toHaveBeenCalledWith( + expect.objectContaining({ + values: { + callRecordingEnabled: true, + callRecordingRetentionDays: 30, + callTranscriptionEnabled: true, + }, + }), + ) + expect(runActionMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-calls-card.test.tsx b/apps/builder/__tests__/whatsapp-calls-card.test.tsx new file mode 100644 index 0000000000..29d62be983 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-calls-card.test.tsx @@ -0,0 +1,155 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { WhatsappCallsCard } from "@/features/integration-whatsapp/calling/whatsapp-calls-card" + +const META_ERROR = "Calling APIs cannot be enabled for this phone number." + +/** Echoes the key back so assertions never depend on the English copy. */ +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +const { toastErrorMock, useActionMock } = vi.hoisted(() => ({ + toastErrorMock: vi.fn(), + useActionMock: vi.fn(), +})) + +vi.mock("sonner", () => ({ + toast: { error: toastErrorMock, success: vi.fn() }, +})) + +vi.mock("next-safe-action/hooks", () => ({ + useAction: useActionMock, +})) + +vi.mock( + "@/features/integration-whatsapp/calling/actions/update-calling-settings.action", + () => ({ + updateWhatsappCallingSettingsAction: { bind: () => vi.fn() }, + }), +) + +vi.mock( + "@/features/integration-whatsapp/calling/actions/fix-whatsapp-calls-subscription.action", + () => ({ + fixWhatsappCallsSubscriptionAction: { bind: () => vi.fn() }, + }), +) + +// jsdom ships no ResizeObserver, and Radix measures the switch thumb through it. +Object.assign(globalThis, { + ResizeObserver: class { + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, +}) + +// jsdom ships no PointerEvent constructor; the Switch's click handler +// re-dispatches one to drive its underlying . +if (typeof globalThis.PointerEvent === "undefined") { + class PointerEventPolyfill extends MouseEvent { + constructor(type: string, params: MouseEventInit = {}) { + super(type, params) + } + } + Object.assign(globalThis, { PointerEvent: PointerEventPolyfill }) +} + +type ActionCallbacks = { + onSuccess: () => void + onError: (args: { error: { serverError?: string } }) => void +} + +describe("WhatsappCallsCard", () => { + let container: HTMLDivElement + let root: Root + let allCallbacks: ActionCallbacks[] + + beforeEach(() => { + vi.clearAllMocks() + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + allCallbacks = [] + useActionMock.mockImplementation( + (_action: unknown, opts: ActionCallbacks) => { + allCallbacks.push(opts) + return { execute: vi.fn(), isPending: false } + }, + ) + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + const render = ( + props: Partial[0]> = {}, + ) => + act(() => { + root.render( + , + ) + }) + + test("keeps Meta's rejection visible in the card after the toast", async () => { + await render() + // Index 0 is the settings-update action — the only `useAction` call in + // the component tree. + const settingsCallbacks = allCallbacks[0] + + act(() => { + settingsCallbacks.onError({ error: { serverError: META_ERROR } }) + }) + + expect(toastErrorMock).toHaveBeenCalledWith(META_ERROR) + const alert = container.querySelector('[role="alert"]') + expect(alert?.textContent).toContain(META_ERROR) + expect(alert?.textContent).toContain("whatsapp.calls.updateFailedTitle") + }) + + test("clears the inline error once a later save succeeds", async () => { + await render() + const settingsCallbacks = allCallbacks[0] + + act(() => { + settingsCallbacks.onError({ error: { serverError: META_ERROR } }) + }) + act(() => { + settingsCallbacks.onSuccess() + }) + + expect(container.querySelector('[role="alert"]')).toBeNull() + }) + + test("reverts the transcription switch when the save fails", async () => { + await render({ + settings: { status: "ENABLED" }, + transcriptionEnabled: false, + }) + const settingsCallbacks = allCallbacks[0] + const switches = container.querySelectorAll('[role="switch"]') + // enable, icon visibility, callback permission, recording, transcription + const transcriptionSwitch = switches[4] + expect(transcriptionSwitch.getAttribute("aria-checked")).toBe("false") + + act(() => { + ;(transcriptionSwitch as HTMLButtonElement).click() + }) + expect(transcriptionSwitch.getAttribute("aria-checked")).toBe("true") + + act(() => { + settingsCallbacks.onError({ error: { serverError: META_ERROR } }) + }) + expect(transcriptionSwitch.getAttribute("aria-checked")).toBe("false") + }) +}) diff --git a/apps/builder/__tests__/whatsapp-connect-webhook-url.test.ts b/apps/builder/__tests__/whatsapp-connect-webhook-url.test.ts index 73b829f462..815ff667bb 100644 --- a/apps/builder/__tests__/whatsapp-connect-webhook-url.test.ts +++ b/apps/builder/__tests__/whatsapp-connect-webhook-url.test.ts @@ -119,6 +119,61 @@ describe("buildAuthValue", () => { expect(input.clientId).toBe(whatsappSettings.clientId) expect(input.clientSecret).toBe(whatsappSettings.clientSecret) }) + + // This is the round-trip the "legacy-unverified" webhook signature policy + // depends on: an owner-supplied Meta App Secret must land in `clientSecret` + // so `resolveSignaturePolicy` (integrations/whatsapp/src/lib/signature-policy.ts) + // selects "enforce" instead of "legacy-unverified" for this integration. + test("manual + a provided app secret stores it as clientSecret instead of clearing it", async () => { + const auth = await buildAuthValue({ + whatsappSettings: { ...whatsappSettings }, + accessToken: "token", + verifyToken: "verify-token", + webhookUrl: `${BROKER_ORIGIN}/integrations/whatsapp/webhook/int-42`, + originUrl: BROKER_ORIGIN, + wabaId: "waba-1", + phoneNumber, + businessId: "biz-1", + isManual: true, + manualAppSecret: "owner-supplied-app-secret", + }) + + expect(auth.clientSecret).toBe("owner-supplied-app-secret") + }) + + test("manual + a whitespace-only app secret is trimmed to empty (still clears clientSecret)", async () => { + const auth = await buildAuthValue({ + whatsappSettings: { ...whatsappSettings }, + accessToken: "token", + verifyToken: "verify-token", + webhookUrl: `${BROKER_ORIGIN}/integrations/whatsapp/webhook/int-42`, + originUrl: BROKER_ORIGIN, + wabaId: "waba-1", + phoneNumber, + businessId: "biz-1", + isManual: true, + manualAppSecret: " ", + }) + + expect(auth.clientSecret).toBe("") + }) + + test("non-manual ignores manualAppSecret entirely, keeping the reseller clientSecret", async () => { + const auth = await buildAuthValue({ + whatsappSettings: { ...whatsappSettings }, + accessToken: "token", + verifyToken: "verify-token", + webhookUrl: `${BROKER_ORIGIN}/integrations/whatsapp/webhook`, + originUrl: BROKER_ORIGIN, + wabaId: "waba-1", + phoneNumber, + businessId: "biz-1", + isManual: false, + manualAppSecret: "should-be-ignored", + }) + + expect(auth.clientSecret).toBe(whatsappSettings.clientSecret) + }) }) describe("buildWabaAuthValue", () => { diff --git a/apps/builder/__tests__/whatsapp-end-voip-call-as-agent.test.ts b/apps/builder/__tests__/whatsapp-end-voip-call-as-agent.test.ts new file mode 100644 index 0000000000..74f53821bd --- /dev/null +++ b/apps/builder/__tests__/whatsapp-end-voip-call-as-agent.test.ts @@ -0,0 +1,286 @@ +// @vitest-environment node + +import { CALL_CANCELED_BY_BUSINESS_LAST_ERROR } from "@chatbotx.io/sdk" +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findByIdMock, + finalizeEndedCallMock, + findByInboxIdForWorkspaceMock, + readControlMock, + endCallMock, + deleteOfferMock, + rejectCallMock, + terminateCallMock, + resolveEndOutcomeWithoutControlMock, +} = vi.hoisted(() => ({ + findByIdMock: vi.fn(), + finalizeEndedCallMock: vi.fn(), + findByInboxIdForWorkspaceMock: vi.fn(), + readControlMock: vi.fn(), + endCallMock: vi.fn(), + deleteOfferMock: vi.fn(), + rejectCallMock: vi.fn(), + terminateCallMock: vi.fn(), + resolveEndOutcomeWithoutControlMock: vi.fn(), +})) + +vi.mock("@/lib/log", () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappVoipCallService: { + readControl: readControlMock, + endCall: endCallMock, + finalizeEndedCall: finalizeEndedCallMock, + resolveEndOutcomeWithoutControl: resolveEndOutcomeWithoutControlMock, + }, + whatsappVoipSignalingService: { + deleteOffer: deleteOfferMock, + }, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error {}, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { + findByInboxIdForWorkspace: findByInboxIdForWorkspaceMock, + }, + whatsappCallRepository: { + findById: findByIdMock, + }, +})) + +vi.mock("@chatbotx.io/integration-whatsapp/api/calling", () => ({ + rejectCall: rejectCallMock, + terminateCall: terminateCallMock, +})) + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string) => key, +})) + +const { endVoipCallAsAgent } = await import( + "../src/features/integration-whatsapp/calling/actions/end-voip-call-as-agent" +) + +const baseInput = { + whatsappCallId: "call-1", + workspaceId: "workspace-1", + userId: "agent-1", + graphFailureLog: "graph failed", +} + +describe("endVoipCallAsAgent", () => { + beforeEach(() => { + vi.clearAllMocks() + finalizeEndedCallMock.mockResolvedValue({ id: "call-1", status: "failed" }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: {}, + }) + readControlMock.mockResolvedValue({ reservedUserId: "agent-1" }) + endCallMock.mockResolvedValue({ + graphAction: "terminate", + terminalStatus: "failed", + }) + deleteOfferMock.mockResolvedValue(undefined) + rejectCallMock.mockResolvedValue(undefined) + terminateCallMock.mockResolvedValue(undefined) + resolveEndOutcomeWithoutControlMock.mockReturnValue(null) + }) + + test("throws callNotFound when the row does not exist", async () => { + findByIdMock.mockResolvedValue(undefined) + await expect(endVoipCallAsAgent(baseInput)).rejects.toThrow( + "whatsapp.calls.errors.callNotFound", + ) + }) + + test("throws callNotFound when the row belongs to a different workspace", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "other-workspace", + wacid: "wacid-1", + inboxId: "inbox-1", + }) + await expect(endVoipCallAsAgent(baseInput)).rejects.toThrow( + "whatsapp.calls.errors.callNotFound", + ) + }) + + test("refuses to cancel another agent's undialed call, before any write", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + wacid: null, + inboxId: "inbox-1", + direction: "businessInitiated", + initiatedByUserId: "agent-2", + answeredByUserId: "agent-2", + }) + + await expect(endVoipCallAsAgent(baseInput)).rejects.toThrow( + "whatsapp.calls.errors.voipNotReservedAgent", + ) + expect(finalizeEndedCallMock).not.toHaveBeenCalled() + }) + + test("a row with wacid === null (not yet dialed) is finalized directly with no Graph call, no control read", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + wacid: null, + inboxId: "inbox-1", + initiatedByUserId: "agent-1", + }) + + const result = await endVoipCallAsAgent(baseInput) + + expect(result).toBe(true) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ whatsappCallId: "call-1", status: "failed" }), + ) + expect(readControlMock).not.toHaveBeenCalled() + expect(endCallMock).not.toHaveBeenCalled() + expect(rejectCallMock).not.toHaveBeenCalled() + expect(terminateCallMock).not.toHaveBeenCalled() + expect(deleteOfferMock).not.toHaveBeenCalled() + expect(findByInboxIdForWorkspaceMock).not.toHaveBeenCalled() + }) + + test("a row WITH a wacid keeps the existing behavior byte-for-byte: reservation check, endCall, Graph action, finalize, delete offer", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + wacid: "wacid-1", + inboxId: "inbox-1", + }) + + const result = await endVoipCallAsAgent(baseInput) + + expect(result).toBe(true) + expect(readControlMock).toHaveBeenCalledWith("wacid-1") + expect(endCallMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + allowFromAccepted: true, + }) + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ whatsappCallId: "call-1", status: "failed" }), + ) + expect(deleteOfferMock).toHaveBeenCalledWith("wacid-1") + }) + + test("a row WITH a wacid throws when reserved by a different agent", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + wacid: "wacid-1", + inboxId: "inbox-1", + }) + readControlMock.mockResolvedValue({ reservedUserId: "other-agent" }) + + await expect(endVoipCallAsAgent(baseInput)).rejects.toThrow( + "whatsapp.calls.errors.voipNotReservedAgent", + ) + }) + + test("a row WITH a wacid returns false when the control was already terminal", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + wacid: "wacid-1", + inboxId: "inbox-1", + }) + endCallMock.mockResolvedValue(undefined) + + await expect(endVoipCallAsAgent(baseInput)).resolves.toBe(false) + expect(finalizeEndedCallMock).not.toHaveBeenCalled() + }) + + test("a failing Graph action never surfaces for a wacid row", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + wacid: "wacid-1", + inboxId: "inbox-1", + }) + terminateCallMock.mockRejectedValue(new Error("meta down")) + + await expect(endVoipCallAsAgent(baseInput)).resolves.toBe(true) + expect(finalizeEndedCallMock).toHaveBeenCalled() + }) + + describe("a row with a wacid but no call control", () => { + const boundOutboundRow = (overrides: Record = {}) => ({ + id: "call-1", + workspaceId: "workspace-1", + wacid: "wacid-1", + inboxId: "inbox-1", + direction: "businessInitiated", + status: "ringing", + initiatedByUserId: "agent-1", + answeredByUserId: "agent-1", + ...overrides, + }) + + beforeEach(() => { + readControlMock.mockResolvedValue(null) + }) + + test("the owner's hangup still ends the call at Meta and closes the row as cancelled", async () => { + const row = boundOutboundRow() + findByIdMock.mockResolvedValue(row) + resolveEndOutcomeWithoutControlMock.mockReturnValue({ + fromPhase: "dialing", + graphAction: "terminate", + terminalStatus: "failed", + }) + + await expect(endVoipCallAsAgent(baseInput)).resolves.toBe(true) + + expect(resolveEndOutcomeWithoutControlMock).toHaveBeenCalledWith(row) + expect(endCallMock).not.toHaveBeenCalled() + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + whatsappCallId: "call-1", + status: "failed", + lastError: CALL_CANCELED_BY_BUSINESS_LAST_ERROR, + }), + ) + }) + + test("rejects a hangup from someone who neither placed nor answered the call", async () => { + findByIdMock.mockResolvedValue( + boundOutboundRow({ + initiatedByUserId: "agent-2", + answeredByUserId: "agent-2", + }), + ) + + await expect(endVoipCallAsAgent(baseInput)).rejects.toThrow( + "whatsapp.calls.errors.voipNotReservedAgent", + ) + expect(terminateCallMock).not.toHaveBeenCalled() + expect(finalizeEndedCallMock).not.toHaveBeenCalled() + }) + + test("a row that is already terminal is a no-op success", async () => { + findByIdMock.mockResolvedValue(boundOutboundRow({ status: "failed" })) + + await expect(endVoipCallAsAgent(baseInput)).resolves.toBe(false) + expect(terminateCallMock).not.toHaveBeenCalled() + expect(rejectCallMock).not.toHaveBeenCalled() + expect(finalizeEndedCallMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-hangup-voip-call-action.test.ts b/apps/builder/__tests__/whatsapp-hangup-voip-call-action.test.ts new file mode 100644 index 0000000000..8d61d4bfa8 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-hangup-voip-call-action.test.ts @@ -0,0 +1,188 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: Record + ctx: { user: { id: string } } +}) => Promise + +const { + findByIdMock, + findByInboxIdForWorkspaceMock, + finalizeEndedCallMock, + readControlMock, + endCallMock, + deleteOfferMock, + terminateCallMock, + rejectCallMock, +} = vi.hoisted(() => ({ + findByIdMock: vi.fn(), + findByInboxIdForWorkspaceMock: vi.fn(), + finalizeEndedCallMock: vi.fn(), + readControlMock: vi.fn(), + endCallMock: vi.fn(), + deleteOfferMock: vi.fn(), + terminateCallMock: vi.fn(), + rejectCallMock: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@/lib/log", () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappVoipCallService: { + readControl: readControlMock, + endCall: endCallMock, + finalizeEndedCall: finalizeEndedCallMock, + }, + whatsappVoipSignalingService: { + deleteOffer: deleteOfferMock, + }, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error {}, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { + findByInboxIdForWorkspace: findByInboxIdForWorkspaceMock, + }, + whatsappCallRepository: { + findById: findByIdMock, + }, +})) + +vi.mock("@chatbotx.io/integration-whatsapp/api/calling", () => ({ + terminateCall: terminateCallMock, + rejectCall: rejectCallMock, +})) + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string) => key, +})) + +const { hangupWhatsappVoipCallAction } = await import( + "../src/features/integration-whatsapp/calling/actions/hangup-voip-call.action" +) +const action = hangupWhatsappVoipCallAction as unknown as ActionHandler + +const ctx = { user: { id: "agent-1" } } + +const call = (whatsappCallId = "call-1") => + action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: { whatsappCallId }, + ctx, + }) + +describe("hangupWhatsappVoipCallAction", () => { + beforeEach(() => { + vi.clearAllMocks() + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-1", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: { tokens: { accessToken: "tok" } }, + }) + readControlMock.mockResolvedValue({ + reservedUserId: "agent-1", + phase: "accepted", + }) + endCallMock.mockResolvedValue({ + fromPhase: "accepted", + graphAction: "terminate", + terminalStatus: "completed", + }) + terminateCallMock.mockResolvedValue(undefined) + rejectCallMock.mockResolvedValue(undefined) + }) + + test("denies a cross-workspace call id", async () => { + findByIdMock.mockResolvedValue({ + id: "call-1", + workspaceId: "workspace-2", + inboxId: "inbox-1", + wacid: "wacid-1", + }) + await expect(call()).rejects.toThrow("whatsapp.calls.errors.callNotFound") + }) + + test("terminates on Meta, finalizes the row as completed, and clears the offer", async () => { + const result = await call() + + expect(endCallMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + allowFromAccepted: true, + }) + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + whatsappCallId: "call-1", + status: "completed", + }), + ) + expect(deleteOfferMock).toHaveBeenCalledWith("wacid-1") + expect(result).toEqual({ hungUp: true }) + }) + + test("honors endCall's graphAction and terminalStatus: a never-accepted (reserved) call uses reject + status rejected, not terminate/completed", async () => { + endCallMock.mockResolvedValue({ + fromPhase: "reserved", + graphAction: "reject", + terminalStatus: "rejected", + }) + + await call() + + expect(rejectCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(terminateCallMock).not.toHaveBeenCalled() + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ whatsappCallId: "call-1", status: "rejected" }), + ) + }) + + test("still finalizes locally (success) when the Graph terminate fails — Meta reclaims on timeout", async () => { + terminateCallMock.mockRejectedValue(new Error("graph 500")) + + const result = await call() + + expect(result).toEqual({ hungUp: true }) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + whatsappCallId: "call-1", + status: "completed", + }), + ) + expect(deleteOfferMock).toHaveBeenCalledWith("wacid-1") + }) + + test("is a no-op success when the call is already terminal", async () => { + endCallMock.mockResolvedValue(null) + + const result = await call() + + expect(result).toEqual({ hungUp: true }) + expect(terminateCallMock).not.toHaveBeenCalled() + expect(finalizeEndedCallMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-heartbeat-active-voip-call-action.test.ts b/apps/builder/__tests__/whatsapp-heartbeat-active-voip-call-action.test.ts new file mode 100644 index 0000000000..e57d20bbe2 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-heartbeat-active-voip-call-action.test.ts @@ -0,0 +1,65 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: Record + ctx: { user: { id: string } } +}) => Promise + +const { heartbeatActiveCallMock } = vi.hoisted(() => ({ + heartbeatActiveCallMock: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappVoipCallService: { heartbeatActiveCall: heartbeatActiveCallMock }, +})) + +const { heartbeatActiveVoipCallAction } = await import( + "../src/features/integration-whatsapp/calling/actions/heartbeat-active-voip-call.action" +) +const action = heartbeatActiveVoipCallAction as unknown as ActionHandler + +describe("heartbeatActiveVoipCallAction", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("delegates to whatsappVoipCallService.heartbeatActiveCall with the workspace-scoped caller and reports ok:true on success", async () => { + heartbeatActiveCallMock.mockResolvedValue(true) + + const result = await action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: { wacid: "wacid-1" }, + ctx: { user: { id: "agent-1" } }, + }) + + expect(heartbeatActiveCallMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + workspaceId: "workspace-1", + userId: "agent-1", + }) + expect(result).toEqual({ ok: true }) + }) + + test("reports ok:false when the call is not applicable (not owned/not accepted)", async () => { + heartbeatActiveCallMock.mockResolvedValue(false) + + const result = await action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: { wacid: "wacid-2" }, + ctx: { user: { id: "agent-1" } }, + }) + + expect(result).toEqual({ ok: false }) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-heartbeat-voip-presence-action.test.ts b/apps/builder/__tests__/whatsapp-heartbeat-voip-presence-action.test.ts new file mode 100644 index 0000000000..9ccabc0313 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-heartbeat-voip-presence-action.test.ts @@ -0,0 +1,49 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: Record + ctx: { user: { id: string } } +}) => Promise + +const { heartbeatMock } = vi.hoisted(() => ({ heartbeatMock: vi.fn() })) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@chatbotx.io/business", () => ({ + whatsappVoipPresenceService: { heartbeat: heartbeatMock }, +})) + +const { heartbeatVoipPresenceAction } = await import( + "../src/features/integration-whatsapp/calling/actions/heartbeat-voip-presence.action" +) +const action = heartbeatVoipPresenceAction as unknown as ActionHandler + +describe("heartbeatVoipPresenceAction", () => { + beforeEach(() => { + vi.clearAllMocks() + heartbeatMock.mockResolvedValue(undefined) + }) + + test("marks the calling agent present in their workspace", async () => { + const result = await action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: {}, + ctx: { user: { id: "agent-1" } }, + }) + + expect(heartbeatMock).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + userId: "agent-1", + }) + expect(result).toEqual({ ok: true }) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-initiate-outbound-voip-call-action.test.ts b/apps/builder/__tests__/whatsapp-initiate-outbound-voip-call-action.test.ts new file mode 100644 index 0000000000..cc12389130 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-initiate-outbound-voip-call-action.test.ts @@ -0,0 +1,648 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: Record + ctx: { user: { id: string } } +}) => Promise + +const { + findByMock, + findInboxMock, + findContactMock, + findByInboxIdForWorkspaceMock, + createOutboundAttemptMock, + attachMetaCallIdMock, + finalizeEndedCallMock, + getCallPermissionsMock, + connectCallMock, + terminateCallMock, + assertNoActiveCallForContactMock, + startOutboundDialMock, + enqueueOutboundDialExpiryMock, + endCallMock, + isCallEndedMock, +} = vi.hoisted(() => ({ + findByMock: vi.fn(), + findInboxMock: vi.fn(), + findContactMock: vi.fn(), + findByInboxIdForWorkspaceMock: vi.fn(), + createOutboundAttemptMock: vi.fn(), + attachMetaCallIdMock: vi.fn(), + finalizeEndedCallMock: vi.fn(), + getCallPermissionsMock: vi.fn(), + connectCallMock: vi.fn(), + terminateCallMock: vi.fn(), + assertNoActiveCallForContactMock: vi.fn(), + startOutboundDialMock: vi.fn(), + enqueueOutboundDialExpiryMock: vi.fn(), + endCallMock: vi.fn(), + isCallEndedMock: vi.fn(), +})) + +class InProgressError extends Error {} +class PendingOutboundExistsError extends Error {} + +class FakeWhatsappException extends Error { + code: string | number + httpStatusCode: number + constructor(message: string, code: string | number, httpStatusCode = 400) { + super(message) + this.code = code + this.httpStatusCode = httpStatusCode + } +} + +const SUPPORTED_ANNOUNCEMENT_LANGUAGES = new Set([ + "en", + "en_US", + "en_AU", + "en_CA", + "en_GB", + "en_IN", + "en_NZ", + "nl", + "fr", + "de", + "hi", + "it", + "kn", + "pt", + "es", + "es_ES", + "te", + "vi", +]) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@/lib/log", () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})) + +vi.mock("@chatbotx.io/business", () => ({ + conversationService: { findBy: findByMock }, + contactInboxService: { findBy: findInboxMock }, + contactService: { findBy: findContactMock }, + whatsappVoipCallService: { + assertNoActiveCallForContact: assertNoActiveCallForContactMock, + startOutboundDial: startOutboundDialMock, + createOutboundAttempt: createOutboundAttemptMock, + attachMetaCallId: attachMetaCallIdMock, + finalizeEndedCall: finalizeEndedCallMock, + endCall: endCallMock, + isCallEnded: isCallEndedMock, + }, + whatsappVoipSignalingService: { + enqueueOutboundDialExpiry: enqueueOutboundDialExpiryMock, + }, + WhatsappCallInProgressError: InProgressError, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error {}, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { whatsapp: "whatsapp" } }, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { + findByInboxIdForWorkspace: findByInboxIdForWorkspaceMock, + }, + WhatsappCallPendingOutboundExistsError: PendingOutboundExistsError, +})) + +vi.mock("@chatbotx.io/integration-whatsapp/api/calling", () => ({ + getCallPermissions: getCallPermissionsMock, + connectCall: connectCallMock, + terminateCall: terminateCallMock, + canPerformCallAction: ( + response: { + actions: { action_name: string; can_perform_action?: boolean }[] + }, + name: string, + ) => + response.actions.find((a) => a.action_name === name)?.can_perform_action === + true, + resolveAnnouncementLanguage: (input: string | undefined) => + input && SUPPORTED_ANNOUNCEMENT_LANGUAGES.has(input) ? input : "en_US", +})) + +vi.mock("@chatbotx.io/integration-whatsapp/exception", () => ({ + WhatsappException: FakeWhatsappException, +})) + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string) => key, +})) + +const { initiateOutboundVoipCallAction } = await import( + "../src/features/integration-whatsapp/calling/actions/initiate-outbound-voip-call.action" +) +const action = initiateOutboundVoipCallAction as unknown as ActionHandler + +const ctx = { user: { id: "agent-1" } } + +const call = (input: Record = {}) => + action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: { + conversationId: "conversation-1", + sdpOffer: "v=0...", + ...input, + }, + ctx, + }) + +describe("initiateOutboundVoipCallAction", () => { + beforeEach(() => { + vi.clearAllMocks() + findByMock.mockResolvedValue({ + id: "conversation-1", + contactId: "contact-1", + inboxId: "inbox-1", + }) + findInboxMock.mockResolvedValue({ + id: "contact-inbox-1", + inboxId: "inbox-1", + channel: "whatsapp", + sourceId: "+15551234567", + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: {}, + displayPhoneNumber: "+44 20 7946 0958", + callRecordingEnabled: true, + callTranscriptionEnabled: false, + callRecordingMode: "browserWhisper", + callTranscriptionMode: "browserWhisper", + callAnnouncementLanguage: null, + callRecordingPurpose: null, + }) + findContactMock.mockResolvedValue({ id: "contact-1", locale: null }) + getCallPermissionsMock.mockResolvedValue({ + messaging_product: "whatsapp", + permission: { status: "permanent" }, + actions: [{ action_name: "start_call", can_perform_action: true }], + }) + assertNoActiveCallForContactMock.mockResolvedValue(undefined) + createOutboundAttemptMock.mockResolvedValue({ id: "call-1" }) + finalizeEndedCallMock.mockResolvedValue({ id: "call-1", status: "failed" }) + connectCallMock.mockResolvedValue({ wacid: "wacid-1" }) + terminateCallMock.mockResolvedValue(undefined) + attachMetaCallIdMock.mockResolvedValue({ id: "call-1", wacid: "wacid-1" }) + startOutboundDialMock.mockResolvedValue({ + reservedUserId: "agent-1", + phase: "dialing", + }) + enqueueOutboundDialExpiryMock.mockResolvedValue(undefined) + endCallMock.mockResolvedValue({ graphAction: "terminate" }) + isCallEndedMock.mockImplementation(({ status }: { status?: string }) => + ["completed", "failed", "rejected"].includes(status ?? ""), + ) + }) + + test("returns ineligibleNumber when the business number's country is blocked (VN)", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: {}, + displayPhoneNumber: "+84 912 345 678", + callRecordingEnabled: false, + }) + await expect(call()).resolves.toEqual({ outcome: "ineligibleNumber" }) + expect(getCallPermissionsMock).not.toHaveBeenCalled() + }) + + // TR is not on Meta's blocked-business-country list (only VN, US, CA, + // EG, NG) — a TR business number must dial normally, not be blocked. + test("does NOT block a TR business number (R11: TR removed from BLOCKED_OUTBOUND_COUNTRIES)", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: {}, + displayPhoneNumber: "+90 532 123 4567", + callRecordingEnabled: true, + callRecordingMode: "browserWhisper", + }) + const result = await call() + expect(result).not.toEqual({ outcome: "ineligibleNumber" }) + expect(getCallPermissionsMock).toHaveBeenCalled() + }) + + test("returns needsPermission when Meta reports no start_call permission", async () => { + getCallPermissionsMock.mockResolvedValue({ + messaging_product: "whatsapp", + permission: { status: "no_permission" }, + actions: [{ action_name: "start_call", can_perform_action: false }], + }) + await expect(call()).resolves.toEqual({ outcome: "needsPermission" }) + expect(createOutboundAttemptMock).not.toHaveBeenCalled() + }) + + test("returns permissionCheckFailed (not needsPermission) when the permissions GET itself throws", async () => { + getCallPermissionsMock.mockRejectedValue(new Error("network")) + await expect(call()).resolves.toEqual({ outcome: "permissionCheckFailed" }) + expect(createOutboundAttemptMock).not.toHaveBeenCalled() + }) + + test("returns callAlreadyInProgress on the glare guard", async () => { + assertNoActiveCallForContactMock.mockRejectedValue( + new InProgressError("contact-inbox-1"), + ) + await expect(call()).resolves.toEqual({ outcome: "callAlreadyInProgress" }) + expect(createOutboundAttemptMock).not.toHaveBeenCalled() + }) + + test("returns callAlreadyInProgress when createPendingOutbound races and loses", async () => { + createOutboundAttemptMock.mockRejectedValue( + new PendingOutboundExistsError("contact-inbox-1"), + ) + await expect(call()).resolves.toEqual({ outcome: "callAlreadyInProgress" }) + expect(connectCallMock).not.toHaveBeenCalled() + }) + + test("propagates an unexpected createPendingOutbound error", async () => { + createOutboundAttemptMock.mockRejectedValue(new Error("db down")) + await expect(call()).rejects.toThrow("db down") + }) + + test("C1: compensates when a post-connect write throws after Meta connect succeeded", async () => { + startOutboundDialMock.mockRejectedValue(new Error("redis down")) + + const result = await call() + + expect(result).toEqual({ outcome: "callFailed" }) + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + whatsappCallId: "call-1", + status: "failed", + lastError: "outbound-setup-failed", + }), + ) + }) + + test("C1: a failing best-effort terminateCall never surfaces — still returns callFailed", async () => { + startOutboundDialMock.mockRejectedValue(new Error("redis down")) + terminateCallMock.mockRejectedValue(new Error("meta down")) + + await expect(call()).resolves.toEqual({ outcome: "callFailed" }) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ whatsappCallId: "call-1", status: "failed" }), + ) + }) + + test("creates the call control before the row exposes the wacid, so a racing hangup always finds one", async () => { + await call() + + expect(startOutboundDialMock.mock.invocationCallOrder[0]).toBeLessThan( + attachMetaCallIdMock.mock.invocationCallOrder[0], + ) + }) + + test("a hangup that closed the row while Meta was connecting wins: the leg is hung up and never dialed", async () => { + attachMetaCallIdMock.mockResolvedValue({ + id: "call-1", + wacid: "wacid-1", + status: "failed", + }) + + await expect(call()).resolves.toEqual({ outcome: "callFailed" }) + + expect(endCallMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + allowFromAccepted: true, + }) + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(enqueueOutboundDialExpiryMock).not.toHaveBeenCalled() + // The row is already closed by the hangup — never overwritten here. + expect(finalizeEndedCallMock).not.toHaveBeenCalled() + }) + + test("treats a vanished attempt row the same as a cancelled one", async () => { + attachMetaCallIdMock.mockResolvedValue(undefined) + + await expect(call()).resolves.toEqual({ outcome: "callFailed" }) + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(enqueueOutboundDialExpiryMock).not.toHaveBeenCalled() + }) + + test("C1: a failing attach after the control exists ends the control, hangs up and finalizes", async () => { + attachMetaCallIdMock.mockRejectedValue(new Error("db down")) + + await expect(call()).resolves.toEqual({ outcome: "callFailed" }) + expect(endCallMock).toHaveBeenCalledWith({ + wacid: "wacid-1", + allowFromAccepted: true, + }) + expect(terminateCallMock).toHaveBeenCalledWith( + expect.objectContaining({ callId: "wacid-1" }), + ) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + whatsappCallId: "call-1", + status: "failed", + lastError: "outbound-setup-failed", + }), + ) + }) + + test("C1: teardown failures never surface — still finalizes and returns callFailed", async () => { + startOutboundDialMock.mockRejectedValue(new Error("redis down")) + endCallMock.mockRejectedValue(new Error("redis still down")) + terminateCallMock.mockRejectedValue(new Error("meta down")) + + await expect(call()).resolves.toEqual({ outcome: "callFailed" }) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ whatsappCallId: "call-1", status: "failed" }), + ) + }) + + test("on success: creates the pending row, connects, attaches wacid, starts the dial, and returns dialing", async () => { + const result = await call() + + expect(createOutboundAttemptMock).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "workspace-1", + inboxId: "inbox-1", + contactInboxId: "contact-inbox-1", + conversationId: "conversation-1", + agentUserId: "agent-1", + }), + ) + expect(connectCallMock).toHaveBeenCalledWith( + expect.objectContaining({ to: "15551234567", sdpOffer: "v=0..." }), + ) + expect(attachMetaCallIdMock).toHaveBeenCalledWith({ + whatsappCallId: "call-1", + wacid: "wacid-1", + }) + expect(startOutboundDialMock).toHaveBeenCalledWith( + expect.objectContaining({ wacid: "wacid-1", initiatorUserId: "agent-1" }), + ) + expect(enqueueOutboundDialExpiryMock).toHaveBeenCalledWith( + expect.objectContaining({ + whatsappCallId: "call-1", + wacid: "wacid-1", + workspaceId: "workspace-1", + }), + ) + expect(result).toEqual( + expect.objectContaining({ + outcome: "dialing", + whatsappCallId: "call-1", + wacid: "wacid-1", + // The default mock integration has callRecordingEnabled: true and + // callRecordingMode: "browserWhisper" — browser recording is + // enabled, and no Meta-native announcement was attached. + browserRecordingEnabled: true, + recordingRequested: true, + }), + ) + }) + + test("R2: a Username/BSUID-only contact (empty sourceId, known sourceUserId) dials via `recipient`, never `to`", async () => { + findInboxMock.mockResolvedValue({ + id: "contact-inbox-1", + inboxId: "inbox-1", + channel: "whatsapp", + sourceId: "", + sourceUserId: "bsuid-123", + }) + + const result = await call() + + expect(connectCallMock).toHaveBeenCalledWith( + expect.objectContaining({ recipient: "bsuid-123", sdpOffer: "v=0..." }), + ) + const connectArgs = connectCallMock.mock.calls[0]?.[0] as Record< + string, + unknown + > + expect(connectArgs.to).toBeUndefined() + // The permissions GET must use the SAME identity shape as the dial — + // Meta's `recipient=`, never a BSUID stuffed into `user_wa_id`. + expect(getCallPermissionsMock).toHaveBeenCalledWith(expect.anything(), { + recipient: "bsuid-123", + }) + expect(result).toEqual(expect.objectContaining({ outcome: "dialing" })) + }) + + test("a phone-number contact looks its permissions up by user_wa_id", async () => { + await call() + + expect(getCallPermissionsMock).toHaveBeenCalledWith(expect.anything(), { + userWaId: "15551234567", + }) + }) + + test("metaNative recording never enables the browser recorder, but still reports recordingRequested", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: {}, + displayPhoneNumber: "+44 20 7946 0958", + callRecordingEnabled: true, + callTranscriptionEnabled: false, + callRecordingMode: "metaNative", + callTranscriptionMode: "metaNative", + callAnnouncementLanguage: null, + callRecordingPurpose: null, + }) + + const result = await call() + + expect(result).toEqual( + expect.objectContaining({ + outcome: "dialing", + browserRecordingEnabled: false, + recordingRequested: true, + }), + ) + }) + + test("recording disabled entirely reports both flags false", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + auth: {}, + displayPhoneNumber: "+44 20 7946 0958", + callRecordingEnabled: false, + callTranscriptionEnabled: false, + callRecordingMode: "browserWhisper", + callTranscriptionMode: "browserWhisper", + callAnnouncementLanguage: null, + callRecordingPurpose: null, + }) + + const result = await call() + + expect(result).toEqual( + expect.objectContaining({ + outcome: "dialing", + browserRecordingEnabled: false, + recordingRequested: false, + }), + ) + }) + + test.each([ + [138_006, "needsPermission"], + [138_000, "callingNotEnabled"], + [138_003, "callAlreadyInProgress"], + [138_012, "dailyLimitReached"], + [138_013, "ineligibleNumber"], + [138_001, "recipientUncallable"], + [138_014, "temporarilyDisabled"], + [138_005, "rateLimited"], + [138_002, "rateLimited"], + [131_044, "paymentIssue"], + [999_999, "callFailed"], + ])("maps Meta error code %s to outcome %s and finalizes the pending row as failed", async (code, outcome) => { + connectCallMock.mockRejectedValue( + new FakeWhatsappException("Meta rejected the call", code), + ) + const result = await call() + + expect(result).toEqual({ outcome }) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + whatsappCallId: "call-1", + status: "failed", + lastError: String(code), + }), + ) + expect(attachMetaCallIdMock).not.toHaveBeenCalled() + expect(startOutboundDialMock).not.toHaveBeenCalled() + }) + + test("a non-WhatsappException connect failure still finalizes the row and returns callFailed", async () => { + connectCallMock.mockRejectedValue(new Error("boom")) + const result = await call() + + expect(result).toEqual({ outcome: "callFailed" }) + expect(finalizeEndedCallMock).toHaveBeenCalledWith( + expect.objectContaining({ whatsappCallId: "call-1", status: "failed" }), + ) + }) + + test("never logs the SDP offer", async () => { + const consoleSpy = vi.spyOn(console, "log") + await call({ sdpOffer: "v=0 SECRET_SDP_CONTENT" }) + const loggedText = JSON.stringify(consoleSpy.mock.calls) + expect(loggedText).not.toContain("SECRET_SDP_CONTENT") + consoleSpy.mockRestore() + }) + + describe("Meta-native recording/transcription announcement options", () => { + const metaNativeIntegration = { + id: "integration-1", + auth: {}, + displayPhoneNumber: "+44 20 7946 0958", + callRecordingEnabled: true, + callTranscriptionEnabled: true, + callRecordingMode: "metaNative", + callTranscriptionMode: "metaNative", + callAnnouncementLanguage: "fr", + callRecordingPurpose: "Quality assurance", + } + + test("passes recording/transcription options to connectCall when both are metaNative", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue(metaNativeIntegration) + + await call() + + const expectedAnnouncement = { + status: "ENABLED", + purpose: "Quality assurance", + announcementLanguage: "fr", + } + expect(connectCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + recording: expectedAnnouncement, + transcription: expectedAnnouncement, + }), + ) + }) + + test("omits both options in browserWhisper mode even when the toggles are on", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + ...metaNativeIntegration, + callRecordingMode: "browserWhisper", + callTranscriptionMode: "browserWhisper", + }) + + await call() + + const connectArgs = connectCallMock.mock.calls[0]?.[0] as Record< + string, + unknown + > + expect(connectArgs.recording).toBeUndefined() + expect(connectArgs.transcription).toBeUndefined() + }) + + test("retries connectCall once without announcement options after a Meta 4xx", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue(metaNativeIntegration) + connectCallMock + .mockRejectedValueOnce( + new FakeWhatsappException("invalid purpose", "invalidParameter", 400), + ) + .mockResolvedValueOnce({ wacid: "wacid-1" }) + const { logger } = await import("@/lib/log") + + const result = await call() + + expect(result).toEqual(expect.objectContaining({ outcome: "dialing" })) + expect(connectCallMock).toHaveBeenCalledTimes(2) + const secondCallArgs = connectCallMock.mock.calls[1]?.[0] as Record< + string, + unknown + > + expect(secondCallArgs.recording).toBeUndefined() + expect(secondCallArgs.transcription).toBeUndefined() + expect(logger.warn).toHaveBeenCalled() + }) + + test("does not retry, and finalizes as failed, when connectCall fails with a non-4xx error", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue(metaNativeIntegration) + connectCallMock.mockRejectedValue( + new FakeWhatsappException("Meta is down", "serverError", 500), + ) + + const result = await call() + + expect(result).toEqual({ outcome: "callFailed" }) + expect(connectCallMock).toHaveBeenCalledTimes(1) + }) + + test("uses the contact's locale for the announcement language when the integration has none configured", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + ...metaNativeIntegration, + callAnnouncementLanguage: null, + }) + findContactMock.mockResolvedValue({ id: "contact-1", locale: "es" }) + + await call() + + expect(connectCallMock).toHaveBeenCalledWith( + expect.objectContaining({ + recording: expect.objectContaining({ announcementLanguage: "es" }), + }), + ) + }) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-manual-webhook-route.test.ts b/apps/builder/__tests__/whatsapp-manual-webhook-route.test.ts new file mode 100644 index 0000000000..f4d1c9d377 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-manual-webhook-route.test.ts @@ -0,0 +1,186 @@ +// @vitest-environment node +import { SdkException } from "@chatbotx.io/sdk" +import { NextRequest } from "next/server" +import { beforeEach, describe, expect, test, vi } from "vitest" + +const findIntegrationWhatsappById = vi.fn() +const markWhatsappWebhookVerified = vi.fn() +const handleRequest = vi.fn() +const logWebhookRequestBody = vi.fn() +const loggerWarn = vi.fn() + +vi.mock("@/features/integration-whatsapp/queries", () => ({ + findIntegrationWhatsappById, + markWhatsappWebhookVerified, +})) + +vi.mock("@/integration", () => ({ + integrations: { whatsapp: { handleRequest } }, +})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: loggerWarn, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock("@/lib/webhook-log", () => ({ + logWebhookRequestBody, +})) + +const { GET, POST } = await import( + "../src/app/integrations/whatsapp/webhook/[integrationId]/route" +) + +const INTEGRATION_ID = "integration-1" + +const makeParams = () => Promise.resolve({ integrationId: INTEGRATION_ID }) + +const manualRow = ( + overrides: Partial<{ + webhookVerifiedAt: string + subscribeOverrideOk: boolean + }> = {}, +) => ({ + phoneNumberId: "phone-number-1", + auth: { + verifyToken: "verify-token", + clientSecret: "", + metadata: { isManual: true, ...overrides }, + }, +}) + +describe("whatsapp manual webhook route — GET handshake", () => { + beforeEach(() => { + findIntegrationWhatsappById.mockReset() + markWhatsappWebhookVerified.mockReset() + }) + + test("returns the challenge and marks the webhook verified on a matching token", async () => { + findIntegrationWhatsappById.mockResolvedValue(manualRow()) + + const request = new NextRequest( + `http://localhost/integrations/whatsapp/webhook/${INTEGRATION_ID}?hub.mode=subscribe&hub.verify_token=verify-token&hub.challenge=challenge-123`, + ) + + const response = await GET(request, { params: makeParams() }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe("challenge-123") + expect(markWhatsappWebhookVerified).toHaveBeenCalledWith( + INTEGRATION_ID, + expect.objectContaining({ verifyToken: "verify-token" }), + ) + }) + + test("returns 403 on a verify_token mismatch", async () => { + findIntegrationWhatsappById.mockResolvedValue(manualRow()) + + const request = new NextRequest( + `http://localhost/integrations/whatsapp/webhook/${INTEGRATION_ID}?hub.mode=subscribe&hub.verify_token=wrong&hub.challenge=challenge-123`, + ) + + const response = await GET(request, { params: makeParams() }) + + expect(response.status).toBe(403) + expect(markWhatsappWebhookVerified).not.toHaveBeenCalled() + }) +}) + +describe("whatsapp manual webhook route — POST", () => { + beforeEach(() => { + findIntegrationWhatsappById.mockReset() + handleRequest.mockReset() + logWebhookRequestBody.mockReset() + loggerWarn.mockClear() + }) + + const makeRequest = (body = "{}") => + new NextRequest( + `http://localhost/integrations/whatsapp/webhook/${INTEGRATION_ID}`, + { + method: "POST", + headers: { "x-hub-signature-256": "sha256=deadbeef" }, + body, + }, + ) + + test("returns 404 when the integration is not found", async () => { + findIntegrationWhatsappById.mockResolvedValue(null) + + const response = await POST(makeRequest(), { params: makeParams() }) + + expect(response.status).toBe(404) + expect(handleRequest).not.toHaveBeenCalled() + }) + + test("returns 403 when the integration has never completed a GET handshake", async () => { + findIntegrationWhatsappById.mockResolvedValue(manualRow()) + + const response = await POST(makeRequest(), { params: makeParams() }) + + expect(response.status).toBe(403) + expect(handleRequest).not.toHaveBeenCalled() + expect(logWebhookRequestBody).not.toHaveBeenCalled() + }) + + test("delegates to integration.handleRequest and logs the body only after it succeeds", async () => { + findIntegrationWhatsappById.mockResolvedValue( + manualRow({ webhookVerifiedAt: "2026-01-01T00:00:00.000Z" }), + ) + handleRequest.mockResolvedValue("ok") + + const response = await POST(makeRequest(), { params: makeParams() }) + + expect(response.status).toBe(200) + expect(handleRequest).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + clientSecret: "", + manualIntegration: true, + integrationId: INTEGRATION_ID, + // Pins every parsed change to this integration's own number. + phoneNumberId: "phone-number-1", + }), + }), + ) + expect(logWebhookRequestBody).toHaveBeenCalledWith( + "whatsapp", + expect.anything(), + ) + }) + + // This is the actual security fix's regression guard for the manual route: + // webhookHandler (integrations/whatsapp/src/handlers/webhook.ts) rejects + // with a 401 SdkException when no clientSecret is configured — manual + // integrations always have an empty clientSecret (see webhook-url.ts's + // buildAuthValue), so this is the path they hit today. + test("surfaces the SdkException's own status (401) on a rejected signature and never logs the body", async () => { + findIntegrationWhatsappById.mockResolvedValue( + manualRow({ webhookVerifiedAt: "2026-01-01T00:00:00.000Z" }), + ) + handleRequest.mockRejectedValue( + new SdkException( + "Whatsapp webhook signature verification failed", + undefined, + 401, + ), + ) + + const response = await POST(makeRequest(), { params: makeParams() }) + + expect(response.status).toBe(401) + expect(logWebhookRequestBody).not.toHaveBeenCalled() + expect(loggerWarn).toHaveBeenCalled() + }) + + test("falls back to 400 for a generic (non-SdkException) handleRequest failure", async () => { + findIntegrationWhatsappById.mockResolvedValue( + manualRow({ webhookVerifiedAt: "2026-01-01T00:00:00.000Z" }), + ) + handleRequest.mockRejectedValue(new Error("boom")) + + const response = await POST(makeRequest(), { params: makeParams() }) + + expect(response.status).toBe(400) + expect(logWebhookRequestBody).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-outbound-voip-turn-credentials-action.test.ts b/apps/builder/__tests__/whatsapp-outbound-voip-turn-credentials-action.test.ts new file mode 100644 index 0000000000..bad6d79dc6 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-outbound-voip-turn-credentials-action.test.ts @@ -0,0 +1,167 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: Record + ctx: { user: { id: string } } +}) => Promise + +const { issueCredentialsMock, incrWithWindowMock } = vi.hoisted(() => ({ + issueCredentialsMock: vi.fn(), + incrWithWindowMock: vi.fn(), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock("@/lib/log", () => ({ + logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})) + +const mockedEnv: { TURN_URL?: string; TURN_STATIC_SECRET?: string } = { + TURN_URL: "turn:turn.example.com", + TURN_STATIC_SECRET: "turn-secret", +} +vi.mock("@/env", () => ({ + get env() { + return mockedEnv + }, +})) + +vi.mock("@chatbotx.io/business", () => ({ + voipTurnCredentialService: { issueCredentials: issueCredentialsMock }, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error { + code?: string + httpStatusCode?: number + constructor(message: string, code?: string, httpStatusCode?: number) { + super(message) + this.code = code + this.httpStatusCode = httpStatusCode + } + }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + distributedStore: { incrWithWindow: incrWithWindowMock }, +})) + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string) => key, +})) + +const { outboundVoipTurnCredentialsAction } = await import( + "../src/features/integration-whatsapp/calling/actions/outbound-voip-turn-credentials.action" +) +const action = outboundVoipTurnCredentialsAction as unknown as ActionHandler + +const call = (userId = "agent-1", attemptId = "client-attempt-1") => + action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: { attemptId }, + ctx: { user: { id: userId } }, + }) + +describe("outboundVoipTurnCredentialsAction", () => { + beforeEach(() => { + vi.clearAllMocks() + mockedEnv.TURN_URL = "turn:turn.example.com" + mockedEnv.TURN_STATIC_SECRET = "turn-secret" + issueCredentialsMock.mockResolvedValue({ + iceServers: [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "turn:turn.example.com", username: "u", credential: "c" }, + ], + turnConfigured: true, + }) + let counter = 0 + incrWithWindowMock.mockImplementation(() => Promise.resolve(++counter)) + }) + + test("requires no call row / wacid — issues credentials from just the caller + a client attempt id", async () => { + const result = await call("agent-1", "client-attempt-1") + + expect(issueCredentialsMock).toHaveBeenCalledWith({ + userId: "agent-1", + wacid: "client-attempt-1", + turnUrl: "turn:turn.example.com", + turnStaticSecret: "turn-secret", + }) + expect(result).toEqual(expect.objectContaining({ turnConfigured: true })) + }) + + test("scopes the identifier to both the caller and the attempt", async () => { + await call("agent-2", "client-attempt-2") + expect(issueCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "agent-2", + wacid: "client-attempt-2", + }), + ) + }) + + test("falls back to STUN-only with a clear indicator when TURN is unconfigured", async () => { + mockedEnv.TURN_STATIC_SECRET = undefined + issueCredentialsMock.mockResolvedValue({ + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + turnConfigured: false, + }) + + const result = await call() + + expect(issueCredentialsMock).toHaveBeenCalledWith( + expect.objectContaining({ turnStaticSecret: undefined }), + ) + expect(result).toEqual(expect.objectContaining({ turnConfigured: false })) + }) + + test("rejects with a rate-limit error once the per-user mint window is exceeded", async () => { + incrWithWindowMock.mockResolvedValue(11) + + await expect(call("agent-1", "attempt-x")).rejects.toMatchObject({ + code: "tooManyRequests", + httpStatusCode: 429, + }) + expect(issueCredentialsMock).not.toHaveBeenCalled() + }) + + test("allows the mint while under the per-user window limit", async () => { + incrWithWindowMock.mockResolvedValue(1) + + await expect(call("agent-1", "attempt-x")).resolves.toEqual( + expect.objectContaining({ turnConfigured: true }), + ) + expect(issueCredentialsMock).toHaveBeenCalled() + }) + + test("rate limit is scoped per user — a different caller is unaffected", async () => { + incrWithWindowMock.mockImplementation((key: string) => + Promise.resolve(key.includes("agent-1") ? 11 : 1), + ) + + await expect(call("agent-1", "attempt-x")).rejects.toMatchObject({ + code: "tooManyRequests", + }) + await expect(call("agent-2", "attempt-y")).resolves.toEqual( + expect.objectContaining({ turnConfigured: true }), + ) + }) + + test("fails open (still mints) when the rate-limit store errors", async () => { + incrWithWindowMock.mockRejectedValue(new Error("redis down")) + + await expect(call()).resolves.toEqual( + expect.objectContaining({ turnConfigured: true }), + ) + expect(issueCredentialsMock).toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-resolve-outbound-call-mode-action.test.ts b/apps/builder/__tests__/whatsapp-resolve-outbound-call-mode-action.test.ts new file mode 100644 index 0000000000..81e1428681 --- /dev/null +++ b/apps/builder/__tests__/whatsapp-resolve-outbound-call-mode-action.test.ts @@ -0,0 +1,366 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +type ActionHandler = (args: { + bindArgsParsedInputs: readonly [string] + parsedInput: Record +}) => Promise + +const { + findByMock, + findInboxMock, + findByInboxIdForWorkspaceMock, + resolveStatusMock, + findWorkspaceByIdMock, + getCallingSettingsMock, + getWhatsappCallingPreflightMock, + withCacheMock, +} = vi.hoisted(() => ({ + findByMock: vi.fn(), + findInboxMock: vi.fn(), + findByInboxIdForWorkspaceMock: vi.fn(), + resolveStatusMock: vi.fn(), + findWorkspaceByIdMock: vi.fn(), + getCallingSettingsMock: vi.fn(), + getWhatsappCallingPreflightMock: vi.fn(), + // Pass through to the wrapped fetcher by default — individual tests + // override this to assert cache-hit/short-circuit behavior. + withCacheMock: vi.fn( + async (_key: string, fn: () => Promise) => await fn(), + ), +})) + +vi.mock("@/lib/safe-action", () => { + const chain: Record = {} + chain.bindArgsSchemas = () => chain + chain.inputSchema = () => chain + chain.action = (handler: unknown) => handler + return { workspaceActionClient: chain } +}) + +vi.mock( + "@/features/integration-whatsapp/calling/get-whatsapp-calling-preflight", + () => ({ + getWhatsappCallingPreflight: getWhatsappCallingPreflightMock, + }), +) + +vi.mock("@chatbotx.io/integration-whatsapp/api/calling", () => ({ + getCallingSettings: getCallingSettingsMock, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + withCache: withCacheMock, +})) + +vi.mock("@chatbotx.io/business", () => ({ + conversationService: { findBy: findByMock }, + contactInboxService: { findBy: findInboxMock }, + whatsappCallPermissionService: { resolveStatus: resolveStatusMock }, + workspaceService: { findById: findWorkspaceByIdMock }, +})) + +vi.mock("@chatbotx.io/business/errors", () => ({ + ChatbotXException: class ChatbotXException extends Error {}, +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + channelTypes: { enum: { whatsapp: "whatsapp" } }, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + integrationWhatsappRepository: { + findByInboxIdForWorkspace: findByInboxIdForWorkspaceMock, + }, +})) + +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string) => key, +})) + +const { resolveOutboundCallModeAction } = await import( + "../src/features/integration-whatsapp/calling/actions/resolve-outbound-call-mode.action" +) +const action = resolveOutboundCallModeAction as unknown as ActionHandler + +const call = (conversationId = "conversation-1") => + action({ + bindArgsParsedInputs: ["workspace-1"], + parsedInput: { conversationId }, + }) + +describe("resolveOutboundCallModeAction", () => { + beforeEach(() => { + vi.clearAllMocks() + withCacheMock.mockImplementation( + async (_key: string, fn: () => Promise) => await fn(), + ) + findByMock.mockResolvedValue({ + id: "conversation-1", + contactId: "contact-1", + inboxId: "inbox-1", + }) + findInboxMock.mockResolvedValue({ + id: "contact-inbox-1", + inboxId: "inbox-1", + channel: "whatsapp", + sourceId: "+15551234567", + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + displayPhoneNumber: "+44 20 7946 0958", + auth: { + clientSecret: "app-secret", + metadata: { isManual: false }, + }, + }) + resolveStatusMock.mockResolvedValue(undefined) + findWorkspaceByIdMock.mockResolvedValue({ id: "workspace-1" }) + getCallingSettingsMock.mockResolvedValue({ status: "ENABLED" }) + getWhatsappCallingPreflightMock.mockResolvedValue({ + isManual: false, + hasAppCredential: true, + callsSubscribed: true, + platformType: "CLOUD_API", + isCloudApiPlatform: true, + messagingLimitTier: "TIER_10K", + messagingLimitSufficient: true, + }) + }) + + test("returns manualCallsSubscriptionUnverified:false for a platform-credential integration", async () => { + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus: undefined, + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: false, + integrationId: "integration-1", + }) + }) + + test("returns manualCallsSubscriptionUnverified:true for a manual integration WITH an app secret", async () => { + getWhatsappCallingPreflightMock.mockResolvedValue({ + isManual: true, + hasAppCredential: false, + callsSubscribed: null, + platformType: null, + isCloudApiPlatform: null, + messagingLimitTier: null, + messagingLimitSufficient: true, + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + displayPhoneNumber: "+44 20 7946 0958", + auth: { clientSecret: "an-app-secret", metadata: { isManual: true } }, + }) + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus: undefined, + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-1", + }) + }) + + test("returns manualCallsSubscriptionUnverified:true for a manual integration WITHOUT an app secret", async () => { + getWhatsappCallingPreflightMock.mockResolvedValue({ + isManual: true, + hasAppCredential: false, + callsSubscribed: null, + platformType: null, + isCloudApiPlatform: null, + messagingLimitTier: null, + messagingLimitSufficient: true, + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + displayPhoneNumber: "+44 20 7946 0958", + auth: { clientSecret: "", metadata: { isManual: true } }, + }) + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus: undefined, + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-1", + }) + }) + + test("throws when the conversation cannot be found", async () => { + findByMock.mockResolvedValue(undefined) + await expect(call()).rejects.toThrow("whatsapp.calls.errors.callNotFound") + }) + + test("returns none/notWhatsappConversation when there is no WhatsApp contact inbox", async () => { + findInboxMock.mockResolvedValue(undefined) + await expect(call()).resolves.toEqual({ + mode: "none", + reason: "notWhatsappConversation", + }) + }) + + test("returns none/callingNotEnabled when calling.status is not ENABLED", async () => { + getCallingSettingsMock.mockResolvedValue({ status: "DISABLED" }) + await expect(call()).resolves.toEqual({ + mode: "none", + reason: "callingNotEnabled", + }) + }) + + test("returns none/tokenInvalid when the calling-settings GET throws", async () => { + getCallingSettingsMock.mockRejectedValue(new Error("401 invalid token")) + await expect(call()).resolves.toEqual({ + mode: "none", + reason: "tokenInvalid", + }) + }) + + test("returns voip + unsignedWebhookWarning for a manually-connected number with no app secret (R4 §6.4)", async () => { + getWhatsappCallingPreflightMock.mockResolvedValue({ + isManual: true, + hasAppCredential: false, + callsSubscribed: null, + platformType: null, + isCloudApiPlatform: null, + messagingLimitTier: null, + messagingLimitSufficient: true, + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + displayPhoneNumber: "+44 20 7946 0958", + auth: { clientSecret: "", metadata: { isManual: true } }, + }) + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus: undefined, + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-1", + }) + }) + + test("does not warn for a manually-connected number that has an app secret configured", async () => { + getWhatsappCallingPreflightMock.mockResolvedValue({ + isManual: true, + hasAppCredential: false, + callsSubscribed: null, + platformType: null, + isCloudApiPlatform: null, + messagingLimitTier: null, + messagingLimitSufficient: true, + }) + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + displayPhoneNumber: "+44 20 7946 0958", + auth: { clientSecret: "an-app-secret", metadata: { isManual: true } }, + }) + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus: undefined, + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-1", + }) + }) + + test("returns none/webhookNotSubscribed when the app has no calls webhook field", async () => { + getWhatsappCallingPreflightMock.mockResolvedValue({ + isManual: false, + hasAppCredential: true, + callsSubscribed: false, + platformType: "CLOUD_API", + isCloudApiPlatform: true, + messagingLimitTier: "TIER_10K", + messagingLimitSufficient: true, + }) + await expect(call()).resolves.toEqual({ + mode: "none", + reason: "webhookNotSubscribed", + }) + }) + + test("returns none/ineligibleNumber for a blocked business country (NG)", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + displayPhoneNumber: "+234 810 123 4567", + auth: { clientSecret: "app-secret", metadata: { isManual: false } }, + }) + await expect(call()).resolves.toEqual({ + mode: "none", + reason: "ineligibleNumber", + }) + }) + + // TR was removed from BLOCKED_OUTBOUND_COUNTRIES — + // it is not in Meta's documented business-initiated-calling block list. + test("returns voip for a TR business number (not in the blocked-country list)", async () => { + findByInboxIdForWorkspaceMock.mockResolvedValue({ + id: "integration-1", + displayPhoneNumber: "+90 532 123 4567", + auth: { clientSecret: "app-secret", metadata: { isManual: false } }, + }) + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus: undefined, + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: false, + integrationId: "integration-1", + }) + }) + + test("returns voip with permissionStatus undefined when no permission row exists", async () => { + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus: undefined, + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: false, + integrationId: "integration-1", + }) + }) + + test.each([ + "no_permission", + "temporary", + "permanent", + ] as const)("returns voip with the %s permission status the service resolves", async (permissionStatus) => { + resolveStatusMock.mockResolvedValue(permissionStatus) + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus, + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: false, + integrationId: "integration-1", + }) + }) + + test("never calls Meta's rate-limited call_permissions GET", async () => { + await call() + // Permission comes from the local record only. + expect(resolveStatusMock).toHaveBeenCalledWith("contact-inbox-1") + }) + + test("caches the calling-settings GET per integration with a TTL", async () => { + await call() + + expect(withCacheMock).toHaveBeenCalledWith( + "whatsapp-outbound-call-mode:calling-settings:integration-1", + expect.any(Function), + expect.objectContaining({ ttl: 5 * 60 }), + ) + expect(getCallingSettingsMock).toHaveBeenCalledTimes(1) + }) + + test("a cache hit skips the live Meta GET entirely", async () => { + withCacheMock.mockResolvedValue({ status: "ENABLED" }) + + await expect(call()).resolves.toEqual({ + mode: "voip", + permissionStatus: undefined, + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: false, + integrationId: "integration-1", + }) + expect(getCallingSettingsMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/whatsapp-settings-actions.test.ts b/apps/builder/__tests__/whatsapp-settings-actions.test.ts index 22849efa69..0a0999e990 100644 --- a/apps/builder/__tests__/whatsapp-settings-actions.test.ts +++ b/apps/builder/__tests__/whatsapp-settings-actions.test.ts @@ -1,11 +1,13 @@ // @vitest-environment node import { beforeEach, describe, expect, test, vi } from "vitest" -const { removeSpy, resolveSpy, upsertSpy } = vi.hoisted(() => ({ - removeSpy: vi.fn(), - resolveSpy: vi.fn(), - upsertSpy: vi.fn(), -})) +const { removeSpy, resolveSpy, upsertSpy, ensureAppWebhookFieldsSpy } = + vi.hoisted(() => ({ + removeSpy: vi.fn(), + resolveSpy: vi.fn(), + upsertSpy: vi.fn(), + ensureAppWebhookFieldsSpy: vi.fn(), + })) vi.mock("@/lib/safe-action", () => { const chain: Record = {} chain.bindArgsSchemas = () => chain @@ -13,9 +15,25 @@ vi.mock("@/lib/safe-action", () => { chain.action = (handler: unknown) => handler return { authActionClient: chain } }) +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})) vi.mock("@chatbotx.io/business", () => ({ platformCredentialService: { remove: removeSpy, upsert: upsertSpy }, })) +vi.mock("@chatbotx.io/integration-whatsapp/api/app-subscriptions", () => ({ + ensureAppWebhookFields: ensureAppWebhookFieldsSpy, + WHATSAPP_APP_SUBSCRIPTION_OBJECT: { + WHATSAPP_BUSINESS_ACCOUNT: "whatsapp_business_account", + }, + WHATSAPP_APP_WEBHOOK_FIELDS: { + CALLS: "calls", + ACCOUNT_SETTINGS_UPDATE: "account_settings_update", + }, +})) +vi.mock("next-intl/server", () => ({ + getTranslations: async () => (key: string) => key, +})) vi.mock("../src/features/platform-credentials/scope", () => ({ credentialScopeSchema: {}, resolveCredentialScopedUserId: resolveSpy, @@ -45,6 +63,10 @@ describe("WhatsApp credential actions", () => { beforeEach(() => { vi.clearAllMocks() resolveSpy.mockReturnValue("user-1") + ensureAppWebhookFieldsSpy.mockResolvedValue({ + status: "already-subscribed", + fields: ["calls"], + }) }) test("upserts all fields", async () => { await call(updateWhatsappSettingsAction)({ @@ -58,6 +80,54 @@ describe("WhatsApp credential actions", () => { config: valid, }) }) + test("subscribes the app-level 'calls' webhook field after saving", async () => { + const result = (await call(updateWhatsappSettingsAction)({ + ctx: { user: { id: "user-1" } }, + bindArgsParsedInputs: ["user"], + parsedInput: valid, + })) as { callsSubscriptionWarning?: string } + + expect(ensureAppWebhookFieldsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + appId: valid.clientId, + appSecret: valid.clientSecret, + verifyToken: valid.verifyToken, + requiredFields: ["calls"], + }), + ) + expect(result.callsSubscriptionWarning).toBeUndefined() + }) + test("returns a warning (but still saves) when no app subscription exists", async () => { + ensureAppWebhookFieldsSpy.mockResolvedValue({ + status: "no-subscription", + fields: [], + }) + + const result = (await call(updateWhatsappSettingsAction)({ + ctx: { user: { id: "user-1" } }, + bindArgsParsedInputs: ["user"], + parsedInput: valid, + })) as { callsSubscriptionWarning?: string } + + expect(upsertSpy).toHaveBeenCalled() + expect(result.callsSubscriptionWarning).toBe( + "whatsapp.calls.preflight.appSubscriptionMissing", + ) + }) + test("returns a warning (but still saves) when the subscription call throws", async () => { + ensureAppWebhookFieldsSpy.mockRejectedValue(new Error("network error")) + + const result = (await call(updateWhatsappSettingsAction)({ + ctx: { user: { id: "user-1" } }, + bindArgsParsedInputs: ["user"], + parsedInput: valid, + })) as { callsSubscriptionWarning?: string } + + expect(upsertSpy).toHaveBeenCalled() + expect(result.callsSubscriptionWarning).toBe( + "whatsapp.calls.preflight.appSubscriptionFailed", + ) + }) test.each([ "clientId", "version", diff --git a/apps/builder/__tests__/whatsapp-voip-call-button.test.tsx b/apps/builder/__tests__/whatsapp-voip-call-button.test.tsx new file mode 100644 index 0000000000..36e9c690dd --- /dev/null +++ b/apps/builder/__tests__/whatsapp-voip-call-button.test.tsx @@ -0,0 +1,664 @@ +import type React from "react" +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { WhatsappVoipCallButton } from "@/features/integration-whatsapp/calling/voip/whatsapp-voip-call-button" + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})) + +vi.mock("@/hooks/routing", () => ({ + useWorkspaceId: () => "workspace-1", +})) + +const { startOutboundMock } = vi.hoisted(() => ({ + startOutboundMock: vi.fn(), +})) + +vi.mock( + "@/features/integration-whatsapp/calling/voip/whatsapp-voip-call-context", + () => ({ + useWhatsappVoipCallContext: () => ({ startOutbound: startOutboundMock }), + }), +) + +vi.mock( + "@/features/integration-whatsapp/calling/request-call-permission-dialog", + () => ({ + RequestCallPermissionDialog: ({ + children, + }: { + children: React.ReactNode + }) =>
{children}
, + }), +) + +describe("WhatsappVoipCallButton", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + type Mode = Parameters[0]["outboundCallMode"] + + const render = (outboundCallMode: Mode, conversationId = "conversation-1") => + act(() => { + root.render( + , + ) + }) + + const click = () => { + const button = container.querySelector("button") + return act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + await Promise.resolve() + }) + } + + const voipMode = ( + overrides: Partial<{ + permissionStatus: "no_permission" | "temporary" | "permanent" | undefined + unsignedWebhookWarning: boolean + manualCallsSubscriptionUnverified: boolean + integrationId: string + }> = {}, + ) => + ({ + mode: "voip" as const, + permissionStatus: undefined, + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: false, + integrationId: "integration-1", + ...overrides, + }) satisfies Mode + + test("renders the request-permission affordance for voip + no_permission", async () => { + await render(voipMode({ permissionStatus: "no_permission" })) + expect( + container.querySelector('[data-testid="request-permission-dialog"]'), + ).not.toBeNull() + expect(startOutboundMock).not.toHaveBeenCalled() + }) + + test("renders the request-permission affordance for voip + undefined permission", async () => { + await render(voipMode({ permissionStatus: undefined })) + expect( + container.querySelector('[data-testid="request-permission-dialog"]'), + ).not.toBeNull() + }) + + test("renders an enabled call button for voip + temporary/permanent permission", async () => { + await render(voipMode({ permissionStatus: "permanent" })) + expect( + container.querySelector('[data-testid="request-permission-dialog"]'), + ).toBeNull() + expect(container.querySelector("button")).not.toBeNull() + }) + + test("renders a disabled button while the mode is still resolving (undefined)", async () => { + await render(undefined) + const button = container.querySelector("button") + expect(button).not.toBeNull() + expect(button?.disabled).toBe(true) + expect( + container.querySelector('[data-testid="request-permission-dialog"]'), + ).toBeNull() + }) + + test("clicking while resolving is a no-op — a click only acts on a resolved permission state (no more 'random' asks)", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render(undefined) + await click() + + expect(startOutboundMock).not.toHaveBeenCalled() + }) + + test("two immediate activations of the direct-dial button only start one outbound call", async () => { + let resolveOutbound: ((value: string) => void) | undefined + startOutboundMock.mockImplementation( + () => + new Promise((resolve) => { + resolveOutbound = resolve + }), + ) + await render(voipMode({ permissionStatus: "permanent" })) + const button = container.querySelector("button") + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + resolveOutbound?.("dialing") + await Promise.resolve() + await Promise.resolve() + }) + + expect(startOutboundMock).toHaveBeenCalledTimes(1) + }) + + test("clicking the enabled call button calls startOutbound with the conversation context", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render(voipMode({ permissionStatus: "permanent" })) + await click() + + expect(startOutboundMock).toHaveBeenCalledWith({ + conversationId: "conversation-1", + contactInboxId: undefined, + contactName: "Ada Lovelace", + }) + expect( + document.querySelector('[data-slot="alert-dialog-content"]'), + ).toBeNull() + }) + + test("opens the capability AlertDialog on a mode: none click, instead of dialing", async () => { + await render({ mode: "none", reason: "callingNotEnabled" }) + await click() + + expect(startOutboundMock).not.toHaveBeenCalled() + expect(document.body.textContent).toContain( + "whatsapp.calls.capability.enableCalling", + ) + }) + + test("maps a webhookNotSubscribed reason to the reconnect-channel message", async () => { + await render({ mode: "none", reason: "webhookNotSubscribed" }) + await click() + + expect(document.body.textContent).toContain( + "whatsapp.calls.capability.reconnectChannel", + ) + }) + + test("opens the AlertDialog on a non-dialing/occupied/cancelled startOutbound outcome", async () => { + startOutboundMock.mockResolvedValue("callAlreadyInProgress") + await render(voipMode({ permissionStatus: "permanent" })) + await click() + + expect(document.body.textContent).toContain( + "whatsapp.calls.outbound.callAlreadyInProgress", + ) + }) + + test("does not open the AlertDialog on 'occupied' (silent local no-op)", async () => { + startOutboundMock.mockResolvedValue("occupied") + await render(voipMode({ permissionStatus: "permanent" })) + await click() + + expect(container.querySelector('[role="alertdialog"]')).toBeNull() + }) + + test("does not open the AlertDialog on 'cancelled' (silent local no-op)", async () => { + startOutboundMock.mockResolvedValue("cancelled") + await render(voipMode({ permissionStatus: "permanent" })) + await click() + + expect(container.querySelector('[role="alertdialog"]')).toBeNull() + }) + + test("a mode:none capability alert uses the eligibility title", async () => { + await render({ mode: "none", reason: "callingNotEnabled" }) + await click() + + expect(document.body.textContent).toContain( + "whatsapp.calls.capability.title", + ) + }) + + test("a mic permission outcome uses the mic-permission title, not the generic eligibility title", async () => { + startOutboundMock.mockResolvedValue("micPermissionDenied") + await render(voipMode({ permissionStatus: "permanent" })) + await click() + + expect(document.body.textContent).toContain( + "whatsapp.calls.capability.micPermissionTitle", + ) + }) + + test("micNotFound also uses the mic-permission title", async () => { + startOutboundMock.mockResolvedValue("micNotFound") + await render(voipMode({ permissionStatus: "permanent" })) + await click() + + expect(document.body.textContent).toContain( + "whatsapp.calls.capability.micPermissionTitle", + ) + }) + + test("a generic dial-failure outcome uses the dial-failure title", async () => { + startOutboundMock.mockResolvedValue("callAlreadyInProgress") + await render(voipMode({ permissionStatus: "permanent" })) + await click() + + expect(document.body.textContent).toContain( + "whatsapp.calls.capability.dialFailureTitle", + ) + }) + + describe("manual integration call warning", () => { + const findButtonByText = (text: string) => + Array.from(document.querySelectorAll("button")).find((button) => + button.textContent?.includes(text), + ) + + const clickCallAnyway = async () => { + const callAnywayButton = findButtonByText( + "whatsapp.calls.manualIntegrationCallWarning.callAnyway", + ) + await act(async () => { + callAnywayButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ) + await Promise.resolve() + }) + } + + test("platform-credential integration: no dialog, dials directly", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: false, + }), + ) + await click() + + expect(startOutboundMock).toHaveBeenCalledWith({ + conversationId: "conversation-1", + contactInboxId: undefined, + contactName: "Ada Lovelace", + }) + expect(document.body.textContent).not.toContain( + "whatsapp.calls.manualIntegrationCallWarning.title", + ) + }) + + test("manual integration WITH an app secret: dialog shows only the calls-subscription paragraph", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: false, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + + expect(startOutboundMock).not.toHaveBeenCalled() + expect(document.body.textContent).toContain( + "whatsapp.calls.manualIntegrationCallWarning.title", + ) + expect(document.body.textContent).toContain( + "whatsapp.calls.manualIntegrationCallWarning.callsSubscription", + ) + expect(document.body.textContent).not.toContain( + "whatsapp.calls.manualIntegrationCallWarning.unsignedWebhook", + ) + }) + + test("manual integration WITHOUT an app secret: dialog shows both paragraphs", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + + expect(startOutboundMock).not.toHaveBeenCalled() + expect(document.body.textContent).toContain( + "whatsapp.calls.manualIntegrationCallWarning.callsSubscription", + ) + expect(document.body.textContent).toContain( + "whatsapp.calls.manualIntegrationCallWarning.unsignedWebhook", + ) + }) + + test("Cancel does not dial", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + + const dialog = container.ownerDocument.querySelector( + '[role="alertdialog"]', + ) + expect(dialog).not.toBeNull() + const cancelButton = findButtonByText("actions.cancel") + expect(cancelButton).not.toBeUndefined() + + await act(async () => { + cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })) + await Promise.resolve() + }) + + expect(startOutboundMock).not.toHaveBeenCalled() + }) + + test("two immediate activations of 'Call anyway' only start one outbound call", async () => { + let resolveOutbound: ((value: string) => void) | undefined + startOutboundMock.mockImplementation( + () => + new Promise((resolve) => { + resolveOutbound = resolve + }), + ) + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + + const callAnywayButton = findButtonByText( + "whatsapp.calls.manualIntegrationCallWarning.callAnyway", + ) + await act(async () => { + callAnywayButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ) + callAnywayButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ) + resolveOutbound?.("dialing") + await Promise.resolve() + await Promise.resolve() + }) + + expect(startOutboundMock).toHaveBeenCalledTimes(1) + }) + + test("'Call anyway' closes the dialog immediately, before the dial resolves", async () => { + let resolveOutbound: ((value: string) => void) | undefined + startOutboundMock.mockImplementation( + () => + new Promise((resolve) => { + resolveOutbound = resolve + }), + ) + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + expect(isWarningDialogOpen()).toBe(true) + + await clickCallAnyway() + + // Dial still in flight, but the warning no longer covers the call panel. + expect(startOutboundMock).toHaveBeenCalledTimes(1) + expect(isWarningDialogOpen()).toBe(false) + + await act(async () => { + resolveOutbound?.("dialing") + await Promise.resolve() + }) + }) + + test("'Call anyway' proceeds with the dial", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + expect(startOutboundMock).not.toHaveBeenCalled() + + await clickCallAnyway() + + expect(startOutboundMock).toHaveBeenCalledWith({ + conversationId: "conversation-1", + contactInboxId: undefined, + contactName: "Ada Lovelace", + }) + }) + + test("does not re-show the warning after 'Call anyway' was already acknowledged for the same integration", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + await clickCallAnyway() + startOutboundMock.mockClear() + + await click() + + expect(startOutboundMock).toHaveBeenCalled() + }) + + test("acknowledgement is NOT reused for a different integration", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-1", + }), + ) + await click() + await clickCallAnyway() + startOutboundMock.mockClear() + + // Same mounted component, different integration/conversation — the + // acknowledgement must not carry over. + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-2", + }), + ) + await click() + + expect(startOutboundMock).not.toHaveBeenCalled() + expect(document.body.textContent).toContain( + "whatsapp.calls.manualIntegrationCallWarning.title", + ) + }) + + // The alert-dialog primitive keeps a closed dialog's markup mounted + // during its exit transition (no "transitionend" in jsdom), so a closed + // dialog is asserted via the absence of `data-open` on its content, + // rather than the element (or its text) being removed from the DOM. + const isWarningDialogOpen = () => + document.querySelector( + '[data-slot="alert-dialog-content"][data-open]', + ) !== null + + test("dialog closes without dialing when outboundCallMode refetches onto a platform integration", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + expect(isWarningDialogOpen()).toBe(true) + + // Navigated to another conversation on a platform-credential + // integration: the mode query refetches onto a non-manual mode. + await render(voipMode({ permissionStatus: "permanent" })) + + expect(isWarningDialogOpen()).toBe(false) + expect(startOutboundMock).not.toHaveBeenCalled() + }) + + test("dialog closes without dialing when outboundCallMode refetches onto a different manual integrationId, and reopens fresh for the new integration", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-1", + }), + ) + await click() + expect(isWarningDialogOpen()).toBe(true) + + // Navigated to a conversation on a different manual integration. + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-2", + }), + ) + + expect(isWarningDialogOpen()).toBe(false) + expect(startOutboundMock).not.toHaveBeenCalled() + + // Clicking again shows the warning for the new integration, not a + // stale acknowledgement or a no-op. + await click() + expect(isWarningDialogOpen()).toBe(true) + expect(startOutboundMock).not.toHaveBeenCalled() + }) + + test("dialog closes without dialing when the conversation changes on the SAME manual integration", async () => { + startOutboundMock.mockResolvedValue("dialing") + const manual = voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-1", + }) + await render(manual, "conversation-1") + await click() + expect(isWarningDialogOpen()).toBe(true) + + // Navigated to another contact on the same number while the dialog was open. + await render(manual, "conversation-2") + + expect(isWarningDialogOpen()).toBe(false) + await clickCallAnyway() + expect(startOutboundMock).not.toHaveBeenCalled() + }) + + test.each([ + ["a platform integration", voipMode({ permissionStatus: "permanent" })], + [ + "a different manual integration", + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-2", + }), + ], + ])("navigating away to %s and back never re-opens the old dialog without a new click", async (_label, otherMode) => { + startOutboundMock.mockResolvedValue("dialing") + const manual = voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-1", + }) + await render(manual) + await click() + expect(isWarningDialogOpen()).toBe(true) + + await render(otherMode) + await render(manual) + + expect(isWarningDialogOpen()).toBe(false) + expect(startOutboundMock).not.toHaveBeenCalled() + }) + + test("navigating to another conversation on the same number and back never re-opens the old dialog", async () => { + startOutboundMock.mockResolvedValue("dialing") + const manual = voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }) + await render(manual, "conversation-1") + await click() + expect(isWarningDialogOpen()).toBe(true) + + await render(manual, "conversation-2") + await render(manual, "conversation-1") + + expect(isWarningDialogOpen()).toBe(false) + }) + + test("the dial lock is released when startOutbound rejects, so a later 'Call anyway' dials again", async () => { + startOutboundMock.mockRejectedValueOnce(new Error("boom")) + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + }), + ) + await click() + await clickCallAnyway() + await act(async () => { + await Promise.resolve() + }) + expect(startOutboundMock).toHaveBeenCalledTimes(1) + + // Acknowledged for this integration, so the next click dials directly. + await click() + expect(startOutboundMock).toHaveBeenCalledTimes(2) + }) + + test("does not render a settings link (manual integration settings has no App Secret field)", async () => { + startOutboundMock.mockResolvedValue("dialing") + await render( + voipMode({ + permissionStatus: "permanent", + unsignedWebhookWarning: true, + manualCallsSubscriptionUnverified: true, + integrationId: "integration-42", + }), + ) + await click() + + expect(document.querySelector('a[href*="integration-42"]')).toBeNull() + }) + }) +}) diff --git a/apps/builder/__tests__/whatsapp-voip-call-context.test.tsx b/apps/builder/__tests__/whatsapp-voip-call-context.test.tsx new file mode 100644 index 0000000000..f1aca25a4d --- /dev/null +++ b/apps/builder/__tests__/whatsapp-voip-call-context.test.tsx @@ -0,0 +1,117 @@ +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" +import { + useWhatsappVoipCallContext, + WhatsappVoipCallProvider, +} from "@/features/integration-whatsapp/calling/voip/whatsapp-voip-call-context" + +const voipCallMock = { + remoteAudioRef: { current: null }, + answer: vi.fn().mockResolvedValue(undefined), + dismiss: vi.fn(), + hangup: vi.fn().mockResolvedValue(undefined), + toggleMute: vi.fn(), + startOutbound: vi.fn().mockResolvedValue("dialing"), +} +const useWhatsappVoipCallSpy = vi.fn(() => voipCallMock) +vi.mock( + "@/features/integration-whatsapp/calling/voip/use-whatsapp-voip-call", + () => ({ useWhatsappVoipCall: () => useWhatsappVoipCallSpy() }), +) + +function ContextConsumer() { + const { answer, dismiss, hangup, toggleMute } = useWhatsappVoipCallContext() + return ( +
+ + + + +
+ ) +} + +describe("WhatsappVoipCallProvider", () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) + vi.clearAllMocks() + container = document.createElement("div") + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + test("calls useWhatsappVoipCall exactly once, even with multiple consumers", () => { + act(() => { + root.render( + + + + , + ) + }) + + expect(useWhatsappVoipCallSpy).toHaveBeenCalledTimes(1) + // Exactly one `