Skip to content

Commit 62ba185

Browse files
committed
feat(voice): add PlivoVoiceProvider with REST API and HMAC-SHA256 webhook verification
1 parent 1cfe26c commit 62ba185

2 files changed

Lines changed: 529 additions & 0 deletions

File tree

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
/**
2+
* @fileoverview Unit tests for {@link PlivoVoiceProvider}.
3+
*
4+
* All HTTP calls are intercepted via an injected `fetchImpl`. Webhook
5+
* verification uses HMAC-SHA256 (v3 scheme). Event mapping is tested
6+
* for every supported Plivo call status, DTMF, and edge cases (JSON body,
7+
* missing params).
8+
*/
9+
10+
import { describe, it, expect, beforeEach, vi } from 'vitest';
11+
import { createHmac } from 'node:crypto';
12+
import { PlivoVoiceProvider } from '../providers/plivo.js';
13+
import type { WebhookContext } from '../types.js';
14+
15+
// ---------------------------------------------------------------------------
16+
// Helpers
17+
// ---------------------------------------------------------------------------
18+
19+
const AUTH_ID = 'MATEST12345';
20+
const AUTH_TOKEN = 'test_plivo_auth_token';
21+
22+
function makeResponse(body: unknown, status = 200): Response {
23+
return {
24+
ok: status >= 200 && status < 300,
25+
status,
26+
json: async () => body,
27+
text: async () => JSON.stringify(body),
28+
} as unknown as Response;
29+
}
30+
31+
/** Compute the expected Plivo v3 HMAC-SHA256 signature. */
32+
function plivoSignature(url: string, nonce: string, authToken: string): string {
33+
const data = url + nonce;
34+
return createHmac('sha256', authToken).update(data).digest('base64');
35+
}
36+
37+
function makeWebhookCtx(
38+
url: string,
39+
body: string,
40+
nonce = 'test-nonce-123',
41+
overrideHeaders?: Record<string, string>,
42+
): WebhookContext {
43+
const sig = plivoSignature(url, nonce, AUTH_TOKEN);
44+
return {
45+
method: 'POST',
46+
url,
47+
headers: {
48+
'x-plivo-signature-v3-nonce': nonce,
49+
'x-plivo-signature-v3': sig,
50+
...overrideHeaders,
51+
},
52+
body,
53+
};
54+
}
55+
56+
// ---------------------------------------------------------------------------
57+
// Tests
58+
// ---------------------------------------------------------------------------
59+
60+
describe('PlivoVoiceProvider', () => {
61+
let fetchMock: ReturnType<typeof vi.fn>;
62+
let provider: PlivoVoiceProvider;
63+
64+
beforeEach(() => {
65+
fetchMock = vi.fn();
66+
provider = new PlivoVoiceProvider({
67+
authId: AUTH_ID,
68+
authToken: AUTH_TOKEN,
69+
fetchImpl: fetchMock as typeof fetch,
70+
});
71+
});
72+
73+
// ── Metadata ───────────────────────────────────────────────────────────
74+
75+
it('has name "plivo"', () => {
76+
expect(provider.name).toBe('plivo');
77+
});
78+
79+
// ── initiateCall ───────────────────────────────────────────────────────
80+
81+
describe('initiateCall()', () => {
82+
it('POSTs to /Account/{authId}/Call/ with correct JSON body', async () => {
83+
fetchMock.mockResolvedValue(makeResponse({ request_uuid: 'req-uuid-001' }));
84+
85+
const result = await provider.initiateCall({
86+
callId: 'call-1',
87+
fromNumber: '+15550000001',
88+
toNumber: '+15550000002',
89+
mode: 'notify',
90+
webhookUrl: 'https://example.com/answer',
91+
});
92+
93+
expect(result).toEqual({ providerCallId: 'req-uuid-001', success: true });
94+
expect(fetchMock).toHaveBeenCalledOnce();
95+
96+
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
97+
expect(url).toBe(`https://api.plivo.com/v1/Account/${AUTH_ID}/Call/`);
98+
expect(options.method).toBe('POST');
99+
100+
const body = JSON.parse(options.body as string);
101+
expect(body.from).toBe('+15550000001');
102+
expect(body.to).toBe('+15550000002');
103+
expect(body.answer_url).toBe('https://example.com/answer');
104+
expect(body.answer_method).toBe('POST');
105+
});
106+
107+
it('sends Basic auth header', async () => {
108+
fetchMock.mockResolvedValue(makeResponse({ request_uuid: 'r2' }));
109+
110+
await provider.initiateCall({
111+
callId: 'c',
112+
fromNumber: '+1',
113+
toNumber: '+2',
114+
mode: 'notify',
115+
webhookUrl: 'https://example.com/wh',
116+
});
117+
118+
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
119+
const expectedAuth = 'Basic ' + Buffer.from(`${AUTH_ID}:${AUTH_TOKEN}`).toString('base64');
120+
expect((options.headers as Record<string, string>).Authorization).toBe(expectedAuth);
121+
});
122+
123+
it('returns success: false on non-2xx response', async () => {
124+
fetchMock.mockResolvedValue(makeResponse({ error: 'not found' }, 404));
125+
126+
const result = await provider.initiateCall({
127+
callId: 'c',
128+
fromNumber: '+1',
129+
toNumber: '+2',
130+
mode: 'notify',
131+
webhookUrl: 'https://example.com/wh',
132+
});
133+
134+
expect(result.success).toBe(false);
135+
expect(result.error).toMatch(/404/);
136+
});
137+
});
138+
139+
// ── hangupCall ─────────────────────────────────────────────────────────
140+
141+
describe('hangupCall()', () => {
142+
it('sends DELETE to /Account/{authId}/Call/{uuid}/', async () => {
143+
fetchMock.mockResolvedValue(makeResponse({}, 204));
144+
145+
await provider.hangupCall({ providerCallId: 'req-uuid-999' });
146+
147+
expect(fetchMock).toHaveBeenCalledOnce();
148+
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
149+
expect(url).toBe(`https://api.plivo.com/v1/Account/${AUTH_ID}/Call/req-uuid-999/`);
150+
expect(options.method).toBe('DELETE');
151+
});
152+
153+
it('includes Basic auth header on DELETE', async () => {
154+
fetchMock.mockResolvedValue(makeResponse({}, 204));
155+
156+
await provider.hangupCall({ providerCallId: 'uuid-aaa' });
157+
158+
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
159+
const expectedAuth = 'Basic ' + Buffer.from(`${AUTH_ID}:${AUTH_TOKEN}`).toString('base64');
160+
expect((options.headers as Record<string, string>).Authorization).toBe(expectedAuth);
161+
});
162+
});
163+
164+
// ── playTts ────────────────────────────────────────────────────────────
165+
166+
describe('playTts()', () => {
167+
it('POSTs to /Account/{authId}/Call/{uuid}/Speak/ with defaults', async () => {
168+
fetchMock.mockResolvedValue(makeResponse({}));
169+
170+
await provider.playTts({ providerCallId: 'uuid-100', text: 'Hello there' });
171+
172+
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
173+
expect(url).toBe(`https://api.plivo.com/v1/Account/${AUTH_ID}/Call/uuid-100/Speak/`);
174+
const body = JSON.parse(options.body as string);
175+
expect(body.text).toBe('Hello there');
176+
expect(body.voice).toBe('WOMAN');
177+
expect(body.language).toBe('en-US');
178+
});
179+
180+
it('uses provided voice when specified', async () => {
181+
fetchMock.mockResolvedValue(makeResponse({}));
182+
183+
await provider.playTts({ providerCallId: 'uuid-101', text: 'Hi', voice: 'MAN' });
184+
185+
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
186+
const body = JSON.parse(options.body as string);
187+
expect(body.voice).toBe('MAN');
188+
});
189+
});
190+
191+
// ── verifyWebhook ──────────────────────────────────────────────────────
192+
193+
describe('verifyWebhook()', () => {
194+
const url = 'https://example.com/plivo/webhook';
195+
const body = 'CallUUID=uuid-001&CallStatus=ringing';
196+
197+
it('returns valid: true for a correctly signed request', () => {
198+
const ctx = makeWebhookCtx(url, body);
199+
expect(provider.verifyWebhook(ctx).valid).toBe(true);
200+
});
201+
202+
it('returns valid: false for a wrong signature', () => {
203+
const ctx = makeWebhookCtx(url, body, 'test-nonce-123', {
204+
'x-plivo-signature-v3': 'wrong_signature',
205+
});
206+
expect(provider.verifyWebhook(ctx).valid).toBe(false);
207+
});
208+
209+
it('returns valid: false when signature header is missing', () => {
210+
const ctx: WebhookContext = {
211+
method: 'POST',
212+
url,
213+
headers: { 'x-plivo-signature-v3-nonce': 'some-nonce' },
214+
body,
215+
};
216+
// Missing signature → computed !== undefined gives false
217+
expect(provider.verifyWebhook(ctx).valid).toBe(false);
218+
});
219+
220+
it('produces different signatures for different nonces', () => {
221+
const ctx1 = makeWebhookCtx(url, body, 'nonce-AAA');
222+
const ctx2 = makeWebhookCtx(url, body, 'nonce-BBB');
223+
// Both should be valid with their respective correct signatures
224+
expect(provider.verifyWebhook(ctx1).valid).toBe(true);
225+
expect(provider.verifyWebhook(ctx2).valid).toBe(true);
226+
});
227+
});
228+
229+
// ── parseWebhookEvent ──────────────────────────────────────────────────
230+
231+
describe('parseWebhookEvent()', () => {
232+
const url = 'https://example.com/plivo/webhook';
233+
234+
const cases: Array<[string, string]> = [
235+
['ringing', 'call-ringing'],
236+
['in-progress', 'call-answered'],
237+
['completed', 'call-completed'],
238+
['busy', 'call-busy'],
239+
['no-answer', 'call-no-answer'],
240+
['failed', 'call-failed'],
241+
];
242+
243+
for (const [plivoStatus, expectedKind] of cases) {
244+
it(`maps CallStatus="${plivoStatus}" → kind="${expectedKind}"`, () => {
245+
const body = `CallUUID=uuid-001&CallStatus=${plivoStatus}`;
246+
const ctx = makeWebhookCtx(url, body);
247+
const result = provider.parseWebhookEvent(ctx);
248+
expect(result.events).toHaveLength(1);
249+
expect(result.events[0].kind).toBe(expectedKind);
250+
expect(result.events[0].providerCallId).toBe('uuid-001');
251+
});
252+
}
253+
254+
it('emits call-dtmf when Digits param is present', () => {
255+
const body = 'CallUUID=uuid-002&CallStatus=in-progress&Digits=9';
256+
const ctx = makeWebhookCtx(url, body);
257+
const result = provider.parseWebhookEvent(ctx);
258+
expect(result.events).toHaveLength(2);
259+
const dtmf = result.events.find(e => e.kind === 'call-dtmf');
260+
expect(dtmf).toBeDefined();
261+
if (dtmf?.kind === 'call-dtmf') {
262+
expect(dtmf.digit).toBe('9');
263+
}
264+
});
265+
266+
it('emits no events for unknown CallStatus', () => {
267+
const body = 'CallUUID=uuid-003&CallStatus=queued';
268+
const ctx = makeWebhookCtx(url, body);
269+
const result = provider.parseWebhookEvent(ctx);
270+
expect(result.events).toHaveLength(0);
271+
});
272+
273+
it('parses a JSON body (fallback path)', () => {
274+
const body = JSON.stringify({ CallUUID: 'uuid-004', CallStatus: 'completed' });
275+
const ctx: WebhookContext = { method: 'POST', url, headers: {}, body };
276+
const result = provider.parseWebhookEvent(ctx);
277+
expect(result.events[0].kind).toBe('call-completed');
278+
expect(result.events[0].providerCallId).toBe('uuid-004');
279+
});
280+
281+
it('assigns unique eventIds to multiple events', () => {
282+
const body = 'CallUUID=uuid-005&CallStatus=in-progress&Digits=1';
283+
const ctx = makeWebhookCtx(url, body);
284+
const result = provider.parseWebhookEvent(ctx);
285+
const ids = result.events.map(e => e.eventId);
286+
expect(new Set(ids).size).toBe(ids.length);
287+
});
288+
});
289+
});

0 commit comments

Comments
 (0)