Packaging
- Drop stale `main: dist/index.js` from package.json (no build workflow)
- Move `prisma` to dependencies and add `postinstall: prisma generate`
so a fresh `npm ci` on the server produces a runnable client
- Add `prisma:deploy` script for `prisma migrate deploy`
- Rename src/services/Twilioservice.ts -> TwilioService.ts (case fix;
invisible on macOS APFS, would crash on Linux)
Node version guards
- .nvmrc (22.23.1) so `nvm use` picks the right runtime
- .npmrc `engine-strict=true` so npm respects `engines` on install
- Runtime guard in scripts/navigator-patch.cjs that fails fast with an
actionable message on Node < 22.18 (native .ts stripping requirement)
Twilio error taxonomy (src/lib/twilioErrors.ts)
- `classifyTwilioError(err)` for send-time exceptions, mapping known
Twilio REST codes (20003/20429/20500/21211..21614) plus HTTP-status
and Node errno fallback to `{ kind: retryable|terminal, category }`
- `classifyDeliveryFailure(code)` for the 30xxx delivery-status family
- `sendViaTwilio` persists a `send_error[_retryable]` Message row on
failure and surfaces the classified message to the user
- `/callback` extracts ErrorCode and passes classified failure info to
`deliveryStatusCard`, which renders the code + retry-hint inline
Persistent PendingMessageStore
- New PrismaPendingMessageStore (upsert-based) becomes the default
singleton; InMemoryPendingMessageStore retained for tests
- `startPendingMessageSweeper()` runs hourly, drops entries >24h old,
unref()s its timer, and is disabled under NODE_ENV=test
- Wired into the shutdown handler in src/index.ts
Tests (39 total, up from 13)
- Integration: tests/integration/twilioRoutes.test.ts hits /sms and
/callback through the real createApp() with a stub bot, real Twilio
signatures, plus /healthz
- Unit: twilioErrors.test.ts (14), pendingMessageStore.test.ts (5)
- typecheck clean
Co-authored-by: Cursor <cursoragent@cursor.com>
123 lines
4.4 KiB
TypeScript
123 lines
4.4 KiB
TypeScript
// tests/integration/twilioRoutes.test.ts
|
|
//
|
|
// End-to-end coverage of the Twilio-facing routes: signature validation,
|
|
// TwiML response, and error-path resilience. The bot is stubbed — we only
|
|
// care about the HTTP surface here.
|
|
//
|
|
// The Prisma-backed lookups fire after `res.send()`, so the response is
|
|
// deterministic even without a populated database. The setup file points
|
|
// DATABASE_URL at `file:./prisma/dev.db`, which exists and has the schema
|
|
// applied from the initial migration.
|
|
|
|
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
|
import request from 'supertest';
|
|
import twilio from 'twilio';
|
|
import type { Express } from 'express';
|
|
|
|
import { createApp } from '../../src/app.ts';
|
|
import prisma from '../../src/services/PrismaService.ts';
|
|
|
|
const AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN!;
|
|
const PUBLIC_BASE = process.env.WEBEX_PUBLIC_URL!.replace(/\/$/, '');
|
|
|
|
/** Minimal shape createApp/buildTwilioRouter actually reaches into. */
|
|
function makeStubBot() {
|
|
return {
|
|
webex: {
|
|
messages: {
|
|
create: async () => ({ id: 'stub' }),
|
|
},
|
|
},
|
|
} as any;
|
|
}
|
|
|
|
function sign(path: string, params: Record<string, string>): string {
|
|
return twilio.getExpectedTwilioSignature(AUTH_TOKEN, `${PUBLIC_BASE}${path}`, params);
|
|
}
|
|
|
|
describe('Twilio routes (integration)', () => {
|
|
let app: Express;
|
|
|
|
beforeAll(() => {
|
|
app = createApp(makeStubBot());
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try { await prisma.$disconnect(); } catch { /* ignore */ }
|
|
});
|
|
|
|
describe('POST /sms', () => {
|
|
it('rejects unsigned requests with 403', async () => {
|
|
const res = await request(app)
|
|
.post('/sms')
|
|
.type('form')
|
|
.send({ From: '+15551234567', To: '+15557654321', Body: 'hi', NumMedia: '0' });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('rejects requests with a bad signature', async () => {
|
|
const res = await request(app)
|
|
.post('/sms')
|
|
.set('X-Twilio-Signature', 'nope')
|
|
.type('form')
|
|
.send({ From: '+15551234567', To: '+15557654321', Body: 'hi', NumMedia: '0' });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('accepts a signed inbound SMS and returns empty TwiML', async () => {
|
|
const params = { From: '+15551234567', To: '+15557654321', Body: 'test body', NumMedia: '0' };
|
|
const res = await request(app)
|
|
.post('/sms')
|
|
.set('X-Twilio-Signature', sign('/sms', params))
|
|
.type('form')
|
|
.send(params);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-type']).toMatch(/xml/);
|
|
expect(res.text).toContain('<Response>');
|
|
expect(res.text).toContain('</Response>');
|
|
});
|
|
});
|
|
|
|
describe('POST /callback', () => {
|
|
it('rejects unsigned requests with 403', async () => {
|
|
const res = await request(app)
|
|
.post('/callback')
|
|
.type('form')
|
|
.send({ SmsSid: 'SM123', SmsStatus: 'delivered' });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('accepts a signed callback and returns TwiML', async () => {
|
|
const params = { SmsSid: 'SM_test_' + Date.now(), SmsStatus: 'delivered' };
|
|
const res = await request(app)
|
|
.post('/callback')
|
|
.set('X-Twilio-Signature', sign('/callback', params))
|
|
.type('form')
|
|
.send(params);
|
|
expect(res.status).toBe(200);
|
|
expect(res.text).toContain('<Response>');
|
|
});
|
|
|
|
it('accepts a signed failure callback with error code', async () => {
|
|
const params = {
|
|
SmsSid: 'SM_fail_' + Date.now(),
|
|
SmsStatus: 'failed',
|
|
ErrorCode: '30003',
|
|
ErrorMessage: 'Unreachable',
|
|
};
|
|
const res = await request(app)
|
|
.post('/callback')
|
|
.set('X-Twilio-Signature', sign('/callback', params))
|
|
.type('form')
|
|
.send(params);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('GET /healthz', () => {
|
|
it('returns 200 without a signature', async () => {
|
|
const res = await request(app).get('/healthz');
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
});
|