sendi/tests/integration/twilioRoutes.test.ts
jmcqueen 28451daca1 Harden packaging, add error taxonomy, persist pending messages
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>
2026-07-06 11:41:18 -04:00

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);
});
});
});