sendi/tests/unit/twilioErrors.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

92 lines
3.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { classifyTwilioError, classifyDeliveryFailure } from '../../src/lib/twilioErrors.ts';
describe('classifyTwilioError', () => {
it('classifies known code 20429 as retryable rate_limit', () => {
const c = classifyTwilioError({ code: 20429, status: 429, message: 'Too many requests' });
expect(c.kind).toBe('retryable');
expect(c.category).toBe('rate_limit');
expect(c.code).toBe(20429);
});
it('classifies known code 21211 as terminal invalid_number', () => {
const c = classifyTwilioError({ code: 21211, status: 400, message: 'Invalid To' });
expect(c.kind).toBe('terminal');
expect(c.category).toBe('invalid_number');
});
it('classifies known code 20003 as terminal authentication', () => {
const c = classifyTwilioError({ code: 20003, status: 401 });
expect(c.kind).toBe('terminal');
expect(c.category).toBe('authentication');
});
it('falls back to HTTP 500 → retryable server', () => {
const c = classifyTwilioError({ status: 502, message: 'Bad gateway' });
expect(c.kind).toBe('retryable');
expect(c.category).toBe('server');
});
it('falls back to HTTP 401 → terminal authentication', () => {
const c = classifyTwilioError({ status: 401 });
expect(c.kind).toBe('terminal');
expect(c.category).toBe('authentication');
});
it('falls back to HTTP 4xx → terminal unknown', () => {
const c = classifyTwilioError({ status: 418 });
expect(c.kind).toBe('terminal');
expect(c.category).toBe('unknown');
});
it('classifies ETIMEDOUT as retryable network', () => {
const c = classifyTwilioError({ errno: 'ETIMEDOUT', message: 'connect ETIMEDOUT' });
expect(c.kind).toBe('retryable');
expect(c.category).toBe('network');
});
it('never throws on odd input shapes', () => {
expect(() => classifyTwilioError(null)).not.toThrow();
expect(() => classifyTwilioError('string error')).not.toThrow();
expect(() => classifyTwilioError(undefined)).not.toThrow();
expect(() => classifyTwilioError(new Error('boom'))).not.toThrow();
});
it('produces a user-safe message with no stack trace', () => {
const c = classifyTwilioError({ code: 21211, message: 'x'.repeat(500) });
expect(c.userMessage).not.toMatch(/xxxxx/);
expect(c.userMessage.length).toBeLessThan(200);
});
});
describe('classifyDeliveryFailure', () => {
it('returns undefined when there is no error code', () => {
expect(classifyDeliveryFailure(undefined)).toBeUndefined();
expect(classifyDeliveryFailure('')).toBeUndefined();
expect(classifyDeliveryFailure(null)).toBeUndefined();
});
it('classifies 30003 → terminal unreachable', () => {
const c = classifyDeliveryFailure('30003');
expect(c?.kind).toBe('terminal');
expect(c?.category).toBe('unreachable');
expect(c?.code).toBe(30003);
});
it('classifies 30007 → terminal carrier_violation (spam)', () => {
const c = classifyDeliveryFailure(30007);
expect(c?.kind).toBe('terminal');
expect(c?.category).toBe('carrier_violation');
});
it('classifies unknown numeric codes as terminal unknown', () => {
const c = classifyDeliveryFailure(99999);
expect(c?.kind).toBe('terminal');
expect(c?.category).toBe('unknown');
expect(c?.code).toBe(99999);
});
it('returns undefined on non-numeric strings', () => {
expect(classifyDeliveryFailure('nope')).toBeUndefined();
});
});