Skip to content

Commit ff455d7

Browse files
committed
feat(voice): add TwilioVoiceProvider with REST API and webhook verification
1 parent 71b36c2 commit ff455d7

2 files changed

Lines changed: 519 additions & 0 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
/**
2+
* @fileoverview Unit tests for {@link TwilioVoiceProvider}.
3+
*
4+
* All HTTP calls are intercepted via an injected `fetchImpl` — no real
5+
* network traffic is made. Webhook verification and event-mapping are
6+
* exercised for every supported call status and DTMF input.
7+
*/
8+
9+
import { describe, it, expect, beforeEach, vi } from 'vitest';
10+
import { createHmac } from 'node:crypto';
11+
import { TwilioVoiceProvider } from '../providers/twilio.js';
12+
import type { WebhookContext } from '../types.js';
13+
14+
// ---------------------------------------------------------------------------
15+
// Helpers
16+
// ---------------------------------------------------------------------------
17+
18+
const ACCOUNT_SID = 'ACtest1234567890';
19+
const AUTH_TOKEN = 'test_auth_token';
20+
21+
/** Build a minimal mock Response. */
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 Twilio HMAC-SHA1 signature for a given url + body. */
32+
function twilioSignature(url: string, body: string): string {
33+
const params = new URLSearchParams(body);
34+
const sorted = [...params.entries()].sort(([a], [b]) => a.localeCompare(b));
35+
let data = url;
36+
for (const [k, v] of sorted) data += k + v;
37+
return createHmac('sha1', AUTH_TOKEN).update(data).digest('base64');
38+
}
39+
40+
/** Build a WebhookContext for a URL-encoded body. */
41+
function makeWebhookCtx(url: string, body: string, overrideHeaders?: Record<string, string>): WebhookContext {
42+
const sig = twilioSignature(url, body);
43+
return {
44+
method: 'POST',
45+
url,
46+
headers: { 'x-twilio-signature': sig, ...overrideHeaders },
47+
body,
48+
};
49+
}
50+
51+
// ---------------------------------------------------------------------------
52+
// Tests
53+
// ---------------------------------------------------------------------------
54+
55+
describe('TwilioVoiceProvider', () => {
56+
let fetchMock: ReturnType<typeof vi.fn>;
57+
let provider: TwilioVoiceProvider;
58+
59+
beforeEach(() => {
60+
fetchMock = vi.fn();
61+
provider = new TwilioVoiceProvider({
62+
accountSid: ACCOUNT_SID,
63+
authToken: AUTH_TOKEN,
64+
fetchImpl: fetchMock as typeof fetch,
65+
});
66+
});
67+
68+
// ── Metadata ───────────────────────────────────────────────────────────
69+
70+
it('has name "twilio"', () => {
71+
expect(provider.name).toBe('twilio');
72+
});
73+
74+
// ── initiateCall ───────────────────────────────────────────────────────
75+
76+
describe('initiateCall()', () => {
77+
it('POSTs to /Accounts/{sid}/Calls.json with correct form body', async () => {
78+
fetchMock.mockResolvedValue(makeResponse({ sid: 'CA001' }));
79+
80+
const result = await provider.initiateCall({
81+
callId: 'call-1',
82+
fromNumber: '+15550000001',
83+
toNumber: '+15550000002',
84+
mode: 'notify',
85+
webhookUrl: 'https://example.com/webhook',
86+
statusCallbackUrl: 'https://example.com/status',
87+
});
88+
89+
expect(result).toEqual({ providerCallId: 'CA001', success: true });
90+
expect(fetchMock).toHaveBeenCalledOnce();
91+
92+
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
93+
expect(url).toBe(`https://api.twilio.com/2010-04-01/Accounts/${ACCOUNT_SID}/Calls.json`);
94+
expect(options.method).toBe('POST');
95+
expect(options.headers).toMatchObject({ 'Content-Type': 'application/x-www-form-urlencoded' });
96+
97+
const body = options.body as string;
98+
expect(body).toContain('To=%2B15550000002');
99+
expect(body).toContain('From=%2B15550000001');
100+
expect(body).toContain('StatusCallbackEvent=initiated');
101+
expect(body).toContain('StatusCallbackEvent=ringing');
102+
expect(body).toContain('StatusCallbackEvent=answered');
103+
expect(body).toContain('StatusCallbackEvent=completed');
104+
});
105+
106+
it('returns success: false with error on non-2xx response', async () => {
107+
fetchMock.mockResolvedValue(makeResponse({ message: 'Not found' }, 404));
108+
109+
const result = await provider.initiateCall({
110+
callId: 'call-2',
111+
fromNumber: '+15550000001',
112+
toNumber: '+15550000002',
113+
mode: 'notify',
114+
webhookUrl: 'https://example.com/webhook',
115+
});
116+
117+
expect(result.success).toBe(false);
118+
expect(result.error).toMatch(/404/);
119+
});
120+
121+
it('sends a Basic auth header', async () => {
122+
fetchMock.mockResolvedValue(makeResponse({ sid: 'CA002' }));
123+
124+
await provider.initiateCall({
125+
callId: 'call-3',
126+
fromNumber: '+1',
127+
toNumber: '+2',
128+
mode: 'notify',
129+
webhookUrl: 'https://example.com/wh',
130+
});
131+
132+
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
133+
const expectedAuth = 'Basic ' + Buffer.from(`${ACCOUNT_SID}:${AUTH_TOKEN}`).toString('base64');
134+
expect((options.headers as Record<string, string>).Authorization).toBe(expectedAuth);
135+
});
136+
});
137+
138+
// ── hangupCall ─────────────────────────────────────────────────────────
139+
140+
describe('hangupCall()', () => {
141+
it('POSTs Status=completed to /Accounts/{sid}/Calls/{callSid}.json', async () => {
142+
fetchMock.mockResolvedValue(makeResponse({}));
143+
144+
await provider.hangupCall({ providerCallId: 'CA999' });
145+
146+
expect(fetchMock).toHaveBeenCalledOnce();
147+
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
148+
expect(url).toContain('/Calls/CA999.json');
149+
expect(options.method).toBe('POST');
150+
expect(options.body).toBe('Status=completed');
151+
});
152+
});
153+
154+
// ── playTts ────────────────────────────────────────────────────────────
155+
156+
describe('playTts()', () => {
157+
it('POSTs TwiML <Say> without voice attribute when voice is omitted', async () => {
158+
fetchMock.mockResolvedValue(makeResponse({}));
159+
160+
await provider.playTts({ providerCallId: 'CA100', text: 'Hello world' });
161+
162+
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
163+
const body = decodeURIComponent(options.body as string).replace('Twiml=', '');
164+
expect(body).toBe('<Response><Say>Hello world</Say></Response>');
165+
});
166+
167+
it('includes voice attribute when voice is provided', async () => {
168+
fetchMock.mockResolvedValue(makeResponse({}));
169+
170+
await provider.playTts({ providerCallId: 'CA101', text: 'Hey there', voice: 'alice' });
171+
172+
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
173+
const body = decodeURIComponent(options.body as string).replace('Twiml=', '');
174+
expect(body).toBe('<Response><Say voice="alice">Hey there</Say></Response>');
175+
});
176+
});
177+
178+
// ── verifyWebhook ──────────────────────────────────────────────────────
179+
180+
describe('verifyWebhook()', () => {
181+
const url = 'https://example.com/twilio/webhook';
182+
const body = 'CallSid=CA001&CallStatus=ringing&From=%2B15550000001';
183+
184+
it('returns valid: true for a correctly signed request', () => {
185+
const ctx = makeWebhookCtx(url, body);
186+
expect(provider.verifyWebhook(ctx)).toEqual({ valid: true });
187+
});
188+
189+
it('returns valid: false for a wrong signature', () => {
190+
const ctx: WebhookContext = {
191+
method: 'POST',
192+
url,
193+
headers: { 'x-twilio-signature': 'wrong_sig' },
194+
body,
195+
};
196+
const result = provider.verifyWebhook(ctx);
197+
expect(result.valid).toBe(false);
198+
expect(result.error).toMatch(/mismatch/i);
199+
});
200+
201+
it('returns valid: false when signature header is missing', () => {
202+
const ctx: WebhookContext = {
203+
method: 'POST',
204+
url,
205+
headers: {},
206+
body,
207+
};
208+
expect(provider.verifyWebhook(ctx).valid).toBe(false);
209+
});
210+
});
211+
212+
// ── parseWebhookEvent ──────────────────────────────────────────────────
213+
214+
describe('parseWebhookEvent()', () => {
215+
const url = 'https://example.com/twilio/webhook';
216+
217+
const cases: Array<[string, string]> = [
218+
['ringing', 'call-ringing'],
219+
['in-progress', 'call-answered'],
220+
['completed', 'call-completed'],
221+
['failed', 'call-failed'],
222+
['busy', 'call-busy'],
223+
['no-answer', 'call-no-answer'],
224+
['canceled', 'call-hangup-user'],
225+
];
226+
227+
for (const [twilioStatus, expectedKind] of cases) {
228+
it(`maps CallStatus="${twilioStatus}" → kind="${expectedKind}"`, () => {
229+
const body = `CallSid=CA001&CallStatus=${twilioStatus}`;
230+
const ctx = makeWebhookCtx(url, body);
231+
const result = provider.parseWebhookEvent(ctx);
232+
expect(result.events).toHaveLength(1);
233+
expect(result.events[0].kind).toBe(expectedKind);
234+
expect(result.events[0].providerCallId).toBe('CA001');
235+
});
236+
}
237+
238+
it('emits call-dtmf event when Digits param is present', () => {
239+
const body = 'CallSid=CA002&CallStatus=in-progress&Digits=5';
240+
const ctx = makeWebhookCtx(url, body);
241+
const result = provider.parseWebhookEvent(ctx);
242+
// Both call-answered and call-dtmf
243+
expect(result.events).toHaveLength(2);
244+
const dtmf = result.events.find(e => e.kind === 'call-dtmf');
245+
expect(dtmf).toBeDefined();
246+
if (dtmf?.kind === 'call-dtmf') {
247+
expect(dtmf.digit).toBe('5');
248+
}
249+
});
250+
251+
it('emits no events for unknown CallStatus', () => {
252+
const body = 'CallSid=CA003&CallStatus=queued';
253+
const ctx = makeWebhookCtx(url, body);
254+
const result = provider.parseWebhookEvent(ctx);
255+
expect(result.events).toHaveLength(0);
256+
});
257+
258+
it('assigns unique eventIds to each event', () => {
259+
const body = 'CallSid=CA004&CallStatus=in-progress&Digits=3';
260+
const ctx = makeWebhookCtx(url, body);
261+
const result = provider.parseWebhookEvent(ctx);
262+
const ids = result.events.map(e => e.eventId);
263+
expect(new Set(ids).size).toBe(ids.length);
264+
});
265+
});
266+
});

0 commit comments

Comments
 (0)