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>
This commit is contained in:
parent
d0a0dda2b8
commit
28451daca1
16 changed files with 592 additions and 54 deletions
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
22.23.1
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const handlers: Record<string, Handler> = {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>((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'); }
|
||||
|
|
|
|||
152
src/lib/twilioErrors.ts
Normal file
152
src/lib/twilioErrors.ts
Normal file
|
|
@ -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<number, { kind: ErrorKind; category: ErrorCategory; userMessage: string }> = {
|
||||
// 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<number, { kind: ErrorKind; category: ErrorCategory; userMessage: string }> = {
|
||||
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}).`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, PendingMessage>();
|
||||
|
|
@ -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<PendingMessage, 'createdAt'>): Promise<void> {
|
||||
await prisma.pendingMessage.upsert({
|
||||
where: { roomId },
|
||||
create: { roomId, text: msg.text },
|
||||
update: { text: msg.text, createdAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async get(roomId: string): Promise<PendingMessage | undefined> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
123
tests/integration/twilioRoutes.test.ts
Normal file
123
tests/integration/twilioRoutes.test.ts
Normal file
|
|
@ -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, 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
49
tests/unit/pendingMessageStore.test.ts
Normal file
49
tests/unit/pendingMessageStore.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
92
tests/unit/twilioErrors.test.ts
Normal file
92
tests/unit/twilioErrors.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue