From 8c4b0eae818c1bd011e608620656c9b13e5aac59 Mon Sep 17 00:00:00 2001 From: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> Date: Fri, 6 Sep 2024 09:48:29 +0200 Subject: [PATCH] DEVEXP-528: E2E Voice/Callbacks --- .../src/services/voice-event.service.ts | 7 +- .../src/models/v1/mod-callbacks/index.ts | 1 + .../v1/mod-callbacks/pie-request/index.ts | 2 +- .../mod-callbacks/pie-request/pie-request.ts | 4 +- .../v1/mod-callbacks/voice-callback-event.ts | 7 + .../rest/v1/callbacks/callbacks-webhook.ts | 9 +- .../v1/callbacks/webhooks-events.steps.ts | 158 ++++++++++++++++++ 7 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 packages/voice/src/models/v1/mod-callbacks/voice-callback-event.ts create mode 100644 packages/voice/tests/rest/v1/callbacks/webhooks-events.steps.ts diff --git a/examples/webhooks/src/services/voice-event.service.ts b/examples/webhooks/src/services/voice-event.service.ts index ba85be68..7c71600e 100644 --- a/examples/webhooks/src/services/voice-event.service.ts +++ b/examples/webhooks/src/services/voice-event.service.ts @@ -1,14 +1,11 @@ import { Injectable } from '@nestjs/common'; import { Response } from 'express'; -import { - VoiceCallback, - Voice, -} from '@sinch/sdk-core'; +import { Voice } from '@sinch/sdk-core'; @Injectable() export class VoiceEventService { - handleEvent(event: VoiceCallback, res: Response) { + handleEvent(event: Voice.VoiceCallbackEvent, res: Response) { console.log(`:: INCOMING EVENT :: ${event.event}`); switch (event.event) { case 'ice': diff --git a/packages/voice/src/models/v1/mod-callbacks/index.ts b/packages/voice/src/models/v1/mod-callbacks/index.ts index 62e23609..c47363b9 100644 --- a/packages/voice/src/models/v1/mod-callbacks/index.ts +++ b/packages/voice/src/models/v1/mod-callbacks/index.ts @@ -8,3 +8,4 @@ export * from './notify-request'; export * from './pie-request'; export * from './pie-response'; export * from './callback-response'; +export * from './voice-callback-event'; diff --git a/packages/voice/src/models/v1/mod-callbacks/pie-request/index.ts b/packages/voice/src/models/v1/mod-callbacks/pie-request/index.ts index 12e1bc6f..57d27616 100644 --- a/packages/voice/src/models/v1/mod-callbacks/pie-request/index.ts +++ b/packages/voice/src/models/v1/mod-callbacks/pie-request/index.ts @@ -1 +1 @@ -export type { PieRequest, MenuResult } from './pie-request'; +export type { PieRequest, MenuResult, PieInformationType } from './pie-request'; diff --git a/packages/voice/src/models/v1/mod-callbacks/pie-request/pie-request.ts b/packages/voice/src/models/v1/mod-callbacks/pie-request/pie-request.ts index 6e5e40b5..dff7c992 100644 --- a/packages/voice/src/models/v1/mod-callbacks/pie-request/pie-request.ts +++ b/packages/voice/src/models/v1/mod-callbacks/pie-request/pie-request.ts @@ -24,9 +24,11 @@ export interface MenuResult { /** The ID of the menu that triggered the prompt input event. */ menuId?: string; /** The type of information that's returned. */ - type?: string; + type?: PieInformationType; /** The value of the returned information. */ value?: string; /** The type of input received. */ inputMethod?: string; } + +export type PieInformationType = 'error' | 'return' | 'sequence' | 'timeout' | 'hangup' | 'invalidinput'; diff --git a/packages/voice/src/models/v1/mod-callbacks/voice-callback-event.ts b/packages/voice/src/models/v1/mod-callbacks/voice-callback-event.ts new file mode 100644 index 00000000..69178239 --- /dev/null +++ b/packages/voice/src/models/v1/mod-callbacks/voice-callback-event.ts @@ -0,0 +1,7 @@ +import { IceRequest } from './ice-request'; +import { AceRequest } from './ace-request'; +import { DiceRequest } from './dice-request'; +import { PieRequest } from './pie-request'; +import { NotifyRequest } from './notify-request'; + +export type VoiceCallbackEvent = IceRequest | AceRequest | DiceRequest | PieRequest | NotifyRequest; diff --git a/packages/voice/src/rest/v1/callbacks/callbacks-webhook.ts b/packages/voice/src/rest/v1/callbacks/callbacks-webhook.ts index b4293b7e..2db77040 100644 --- a/packages/voice/src/rest/v1/callbacks/callbacks-webhook.ts +++ b/packages/voice/src/rest/v1/callbacks/callbacks-webhook.ts @@ -1,10 +1,11 @@ -import { AceRequest, DiceRequest, IceRequest, NotifyRequest, PieRequest } from '../../../models'; +import { AceRequest, DiceRequest, IceRequest, NotifyRequest, PieRequest, VoiceCallbackEvent } from '../../../models'; import { CallbackProcessor, SinchClientParameters, validateAuthenticationHeader } from '@sinch/sdk-client'; import { IncomingHttpHeaders } from 'http'; +/** @deprecated - use Voice.VoiceCallbackEvent instead */ export type VoiceCallback = IceRequest | AceRequest | DiceRequest | PieRequest | NotifyRequest; -export class VoiceCallbackWebhooks implements CallbackProcessor{ +export class VoiceCallbackWebhooks implements CallbackProcessor{ private readonly sinchClientParameters: SinchClientParameters; constructor(sinchClientParameters: SinchClientParameters) { @@ -38,9 +39,9 @@ export class VoiceCallbackWebhooks implements CallbackProcessor{ * Reviver for a Voice Event. * This method ensures the object can be treated as a Voice Event and should be called before any action is taken to manipulate the object. * @param {any} eventBody - The event body containing the voice event notification. - * @return {VoiceCallback} - The parsed voice event object. + * @return {VoiceCallbackEvent} - The parsed voice event object. */ - public parseEvent(eventBody: any): VoiceCallback { + public parseEvent(eventBody: any): VoiceCallbackEvent { if (eventBody.timestamp) { eventBody.timestamp = new Date(eventBody.timestamp); } diff --git a/packages/voice/tests/rest/v1/callbacks/webhooks-events.steps.ts b/packages/voice/tests/rest/v1/callbacks/webhooks-events.steps.ts new file mode 100644 index 00000000..e997d8ad --- /dev/null +++ b/packages/voice/tests/rest/v1/callbacks/webhooks-events.steps.ts @@ -0,0 +1,158 @@ +import { VoiceCallbackWebhooks, Voice } from '../../../../src'; +import { Given, Then, When } from '@cucumber/cucumber'; +import assert from 'assert'; +import { IncomingHttpHeaders } from 'http'; + +let voiceCallbackWebhooks: VoiceCallbackWebhooks; +let rawEvent: any; +let event: Voice.VoiceCallbackEvent; +let formattedHeaders: IncomingHttpHeaders; + +const processEvent = async (response: Response) => { + formattedHeaders = {}; + response.headers.forEach((value, name) => { + formattedHeaders[name.toLowerCase()] = value; + }); + rawEvent = await response.text(); + event = voiceCallbackWebhooks.parseEvent(JSON.parse(rawEvent)); +}; + + +Given('the Voice Webhooks handler is available', () => { + voiceCallbackWebhooks = new VoiceCallbackWebhooks({ + applicationKey: 'appKey', + applicationSecret: 'appSecret', + }); +}); + +When('I send a request to trigger a "PIE" event with a "return" type', async () => { + const response = await fetch('http://localhost:3019/webhooks/voice/pie-return'); + await processEvent(response); +}); + +Then('the header of the "PIE" event with a "return" type contains a valid authorization', () => { + assert.ok(voiceCallbackWebhooks.validateAuthenticationHeader( + formattedHeaders, + rawEvent, + '/webhooks/voice', + 'POST')); +}); + +Then('the Voice event describes a "PIE" event with a "return" type', () => { + const pieEvent = event as Voice.PieRequest; + assert.equal(pieEvent.callid, '1ce0ffee-ca11-ca11-ca11-abcdef000013'); + assert.equal(pieEvent.event, 'pie'); + const menuResult: Voice.MenuResult = { + type: 'return', + value: 'cancel', + menuId: 'main', + inputMethod: 'dtmf', + }; + assert.ok(pieEvent.menuResult); + assert.equal(pieEvent.menuResult.type, menuResult.type); + assert.equal(pieEvent.menuResult.value, menuResult.value); + assert.equal(pieEvent.menuResult.menuId, menuResult.menuId); + assert.equal(pieEvent.menuResult.inputMethod, menuResult.inputMethod); + assert.equal(pieEvent.version, 1); + assert.equal(pieEvent.custom, 'Custom text'); + assert.equal(pieEvent.applicationKey, 'f00dcafe-abba-c0de-1dea-dabb1ed4caf3'); +}); + +When('I send a request to trigger a "PIE" event with a "sequence" type', async () => { + const response = await fetch('http://localhost:3019/webhooks/voice/pie-sequence'); + await processEvent(response); +}); + +Then('the header of the "PIE" event with a "sequence" type contains a valid authorization', () => { + assert.ok(voiceCallbackWebhooks.validateAuthenticationHeader( + formattedHeaders, + rawEvent, + '/webhooks/voice', + 'POST')); +}); + +Then('the Voice event describes a "PIE" event with a "sequence" type', () => { + const pieEvent = event as Voice.PieRequest; + assert.equal(pieEvent.callid, '1ce0ffee-ca11-ca11-ca11-abcdef000023'); + assert.equal(pieEvent.event, 'pie'); + const menuResult: Voice.MenuResult = { + type: 'sequence', + value: '1234', + menuId: 'confirm', + inputMethod: 'dtmf', + }; + assert.ok(pieEvent.menuResult); + assert.equal(pieEvent.menuResult.type, menuResult.type); + assert.equal(pieEvent.menuResult.value, menuResult.value); + assert.equal(pieEvent.menuResult.menuId, menuResult.menuId); + assert.equal(pieEvent.menuResult.inputMethod, menuResult.inputMethod); + assert.equal(pieEvent.version, 1); + assert.equal(pieEvent.custom, 'Custom text'); + assert.equal(pieEvent.applicationKey, 'f00dcafe-abba-c0de-1dea-dabb1ed4caf3'); +}); + +When('I send a request to trigger a "DICE" event', async () => { + const response = await fetch('http://localhost:3019/webhooks/voice/dice'); + await processEvent(response); +}); + +Then('the header of the "DICE" event contains a valid authorization', () => { + assert.ok(voiceCallbackWebhooks.validateAuthenticationHeader( + formattedHeaders, + rawEvent, + '/webhooks/voice', + 'POST')); +}); + +Then('the Voice event describes a "DICE" event', () => { + const diceEvent = event as Voice.DiceRequest; + assert.equal(diceEvent.callid, '1ce0ffee-ca11-ca11-ca11-abcdef000033'); + assert.equal(diceEvent.event, 'dice'); + const reason: Voice.ReasonEnum = 'MANAGERHANGUP'; + assert.equal(diceEvent.reason, reason); + const result: Voice.ResultEnum = 'ANSWERED'; + assert.equal(diceEvent.result, result); + assert.equal(diceEvent.version, 1); + assert.equal(diceEvent.custom, 'Custom text'); + const debit: Voice.VoicePrice = { + currencyId: 'EUR', + amount: 0.0095, + }; + assert.deepEqual(diceEvent.userRate, debit); + const userRate: Voice.VoicePrice = { + currencyId: 'EUR', + amount: 0.0095, + }; + assert.deepEqual(diceEvent.userRate, userRate); + const destinationParticipant: Voice.Participant = { + type: 'number', + endpoint: '12017777777', + }; + assert.deepEqual(diceEvent.to, destinationParticipant); + assert.equal(diceEvent.applicationKey, 'f00dcafe-abba-c0de-1dea-dabb1ed4caf3'); + assert.equal(diceEvent.duration, 12); + assert.equal(diceEvent.from, '12015555555'); +}); + +When('I send a request to trigger a "ACE" event', async () => { + const response = await fetch('http://localhost:3019/webhooks/voice/ace'); + await processEvent(response); +}); + +Then('the header of the "ACE" event contains a valid authorization', () => { + assert.ok(voiceCallbackWebhooks.validateAuthenticationHeader( + formattedHeaders, + rawEvent, + '/webhooks/voice', + 'POST')); +}); + +Then('the Voice event describes a "ACE" event', () => { + const aceEvent = event as Voice.AceRequest; + assert.equal(aceEvent.callid, '1ce0ffee-ca11-ca11-ca11-abcdef000043'); + assert.equal(aceEvent.event, 'ace'); + assert.deepEqual(aceEvent.timestamp, new Date('2024-06-06T17:10:34Z')); + assert.equal(aceEvent.version, 1); + assert.equal(aceEvent.custom, 'Custom text'); + assert.equal(aceEvent.applicationKey, 'f00dcafe-abba-c0de-1dea-dabb1ed4caf3'); +});