From 1c14925f54b5af8e1ec751449cfddfcd741cadf9 Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 10:54:16 +0000 Subject: [PATCH 1/6] feat: implement WhatsApp AI agent communication layer with Twilio webhooks --- jest.config.js | 4 +- package-lock.json | 372 +++++++++++++++++++++++++++++ package.json | 3 + prisma/schema.prisma | 10 +- prisma/seed.ts | 4 + src/index.ts | 4 + src/routes/whatsapp.ts | 45 ++++ src/whatsapp/handler.ts | 108 +++++++++ src/whatsapp/userManager.ts | 129 ++++++++++ tests/integration/whatsapp.test.ts | 194 +++++++++++++++ 10 files changed, 870 insertions(+), 3 deletions(-) create mode 100644 src/routes/whatsapp.ts create mode 100644 src/whatsapp/handler.ts create mode 100644 src/whatsapp/userManager.ts create mode 100644 tests/integration/whatsapp.test.ts diff --git a/jest.config.js b/jest.config.js index 885dd79..37cbd60 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,8 +1,8 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/src'], - testMatch: ['**/__tests__/**/*.test.ts'], + roots: ['/src', '/tests'], + testMatch: ['**/__tests__/**/*.test.ts', '**/tests/**/*.test.ts'], moduleFileExtensions: ['ts', 'js'], collectCoverageFrom: [ 'src/**/*.ts', diff --git a/package-lock.json b/package-lock.json index 8bfca82..379a746 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,9 +24,12 @@ "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.3.0", + "@types/supertest": "^7.2.0", + "@types/twilio": "^3.19.2", "jest": "^30.2.0", "nodemon": "^3.1.14", "prisma": "^5.22.0", + "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -83,6 +86,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1187,6 +1191,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1467,6 +1481,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -1547,12 +1568,20 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1599,12 +1628,46 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/twilio": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/@types/twilio/-/twilio-3.19.2.tgz", + "integrity": "sha512-yMEBc7xS1G4Dd4w5xvfDIJkSVVZmiGP/Lrpr4QqUus9rENPjt9BUag5NL198cO2EoJNI8Tqy8qMcKO9jd+9Ssg==", + "dev": true, + "license": "MIT", + "dependencies": { + "twilio": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -1937,6 +2000,19 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2033,6 +2109,13 @@ "sprintf-js": "~1.0.2" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2314,6 +2397,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2375,6 +2459,13 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2740,6 +2831,16 @@ "node": ">=20" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2794,6 +2895,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -2833,6 +2941,13 @@ "node": ">= 8" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2920,6 +3035,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -2963,6 +3089,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3188,6 +3324,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -3251,6 +3388,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3419,6 +3563,24 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3448,6 +3610,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3753,6 +3916,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4142,6 +4319,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -4806,6 +4984,52 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -4842,6 +5066,48 @@ "node": ">=8" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -4849,6 +5115,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -4946,6 +5219,16 @@ "dev": true, "license": "MIT" }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4960,6 +5243,19 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -5450,6 +5746,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -5672,6 +5969,14 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -6146,6 +6451,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -6395,6 +6736,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -6441,6 +6783,25 @@ "license": "0BSD", "optional": true }, + "node_modules/twilio": { + "version": "5.12.2", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.12.2.tgz", + "integrity": "sha512-yjTH04Ig0Z3PAxIXhwrto0IJC4Gv7lBDQQ9f4/P9zJhnxVdd+3tENqBMJOtdmmRags3X0jl2IGKEQefCEpJE9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.12.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.2", + "qs": "^6.14.1", + "scmp": "^2.1.0", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -6498,6 +6859,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6868,6 +7230,16 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 243d679..7f69158 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,12 @@ "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^25.3.0", + "@types/supertest": "^7.2.0", + "@types/twilio": "^3.19.2", "jest": "^30.2.0", "nodemon": "^3.1.14", "prisma": "^5.22.0", + "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf1db2a..663458e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -57,7 +57,14 @@ model User { email String? @unique avatarUrl String? riskTolerance Int @default(5) - isActive Boolean @default(true) + isActive Boolean @default(true) // will be false until OTP verification on WhatsApp + + // WhatsApp integration fields + phoneNumber String? @unique + walletSecretEncrypted String? // encrypted via KMS or similar (stubbed) + otpCode String? + otpExpiry DateTime? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -67,6 +74,7 @@ model User { agentLogs AgentLog[] @@index([walletAddress]) + @@index([phoneNumber]) @@map("users") } diff --git a/prisma/seed.ts b/prisma/seed.ts index c4cf219..f8caa30 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -16,6 +16,10 @@ async function main() { displayName: 'Alex Testnet', email: 'alex@neurowealth.dev', riskTolerance: 6, + phoneNumber: '+15551234567', + isActive: true, + walletSecretEncrypted: Buffer.from('SOMESECRET').toString('base64'), + // OTP fields left null since user is already active }, }) console.log(`✅ User: ${user.displayName} (${user.id})`) diff --git a/src/index.ts b/src/index.ts index 9f84228..5558b1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,10 @@ app.use(rateLimiter) // Routes app.use('/health', healthRouter) +// WhatsApp webhook endpoints +import whatsappRouter from './routes/whatsapp'; +app.use('/api/whatsapp', whatsappRouter) + // Global error handler — must always be last app.use(errorHandler) diff --git a/src/routes/whatsapp.ts b/src/routes/whatsapp.ts new file mode 100644 index 0000000..92b1a5d --- /dev/null +++ b/src/routes/whatsapp.ts @@ -0,0 +1,45 @@ +import express, { Router, Request, Response } from 'express'; +import { config } from '../config/env'; +import { handleWhatsAppMessage } from '../whatsapp/handler'; +import twilio from 'twilio'; + +const router = Router(); + +// Twilio sends form-encoded data for messaging webhooks +router.use(express.urlencoded({ extended: false })); + +// Health check +router.get('/webhook', (req: Request, res: Response) => { + res.status(200).send('OK'); +}); + +// Message receiver +router.post('/webhook', async (req: Request, res: Response) => { + try { + const signature = req.header('x-twilio-signature') || ''; + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`; + const valid = twilio.validateRequest( + config.whatsapp.twilioToken, + signature, + url, + req.body + ); + if (!valid) { + return res.status(403).send('Invalid Twilio signature'); + } + + const from = req.body.From || ''; + const body = req.body.Body || ''; + + const responseText = await handleWhatsAppMessage(from, body); + + const twiml = new twilio.twiml.MessagingResponse(); + twiml.message(responseText); + res.type('text/xml').send(twiml.toString()); + } catch (err) { + console.error('whatsapp webhook error', err); + res.status(500).send('Server error'); + } +}); + +export default router; diff --git a/src/whatsapp/handler.ts b/src/whatsapp/handler.ts new file mode 100644 index 0000000..4ca2aae --- /dev/null +++ b/src/whatsapp/handler.ts @@ -0,0 +1,108 @@ +import { parseIntent } from '../nlp/parser'; +import userManager from './userManager'; +import db from '../db'; + +export async function handleWhatsAppMessage(from: string, message: string): Promise { + const phone = from.replace(/^whatsapp:/, ''); + // ensure user exists (or create) + const user = await userManager.getOrCreateUser(phone); + + // onboarding / OTP flow + if (!user.isActive) { + const trimmed = message.trim(); + // if user enters exactly 6 digits, treat as OTP attempt + if (/^\d{6}$/.test(trimmed)) { + const ok = await userManager.verifyOtpCode(phone, trimmed); + if (ok) { + return '✅ Your phone number is verified! You can now send commands like "balance", "deposit 100", or "withdraw 50".'; + } else { + // maybe expired or wrong, ask to resend + const newCode = await userManager.generateAndSaveOtp(phone); + return `❌ Invalid or expired code. I've sent you a new OTP: ${newCode} (expires in 5 minutes).`; + } + } + + // otherwise, send OTP to new user + const otp = await userManager.generateAndSaveOtp(phone); + return `Welcome to NeuroWealth! To get started we need to verify your phone number. Please reply with this 6-digit code: ${otp} (valid for 5 minutes).`; + } + + // active user – parse intent + const intent = await parseIntent(message); + + switch (intent.action) { + case 'balance': { + const balance = await userManager.calculateBalance(phone); + return `💰 Your current portfolio value is ${balance.toFixed(2)} XLM (approx).`; + } + + case 'deposit': { + if (!intent.amount || intent.amount <= 0) { + return 'Please specify an amount to deposit, e.g. "deposit 100".'; + } + const currency = intent.currency || 'XLM'; + // create a pending transaction record for bookkeeping + await db.transaction.create({ + data: { + userId: user.id, + type: 'DEPOSIT', + assetSymbol: currency, + amount: intent.amount, + network: user.network, + }, + }); + return `To deposit ${intent.amount} ${currency}, send funds to your Stellar wallet ${user.walletAddress}. \nOnce confirmed, your balance will update automatically.`; + } + + case 'withdraw': { + if (intent.all) { + const bal = await userManager.calculateBalance(phone); + if (bal <= 0) { + return "You don't have any funds available to withdraw."; + } + // create a withdrawal request + await db.transaction.create({ + data: { + userId: user.id, + type: 'WITHDRAWAL', + assetSymbol: 'XLM', + amount: bal, + network: user.network, + status: 'PENDING', + }, + }); + return `Your request to withdraw all (${bal.toFixed(2)} XLM) has been received and is being processed.`; + } + + if (!intent.amount || intent.amount <= 0) { + return 'Please specify an amount to withdraw, e.g. "withdraw 50" or "withdraw all".'; + } + + const bal = await userManager.calculateBalance(phone); + if (intent.amount > bal) { + return `You requested ${intent.amount} but only have ${bal.toFixed(2)} available.`; + } + await db.transaction.create({ + data: { + userId: user.id, + type: 'WITHDRAWAL', + assetSymbol: intent.currency || 'XLM', + amount: intent.amount, + network: user.network, + status: 'PENDING', + }, + }); + return `Your withdrawal of ${intent.amount} ${intent.currency || 'XLM'} is being processed.`; + } + + // help and unknown both send help text + case 'help': + case 'unknown': + default: { + return `I can help you manage your funds. Send: +- "balance" to see your portfolio +- "deposit 100" to get deposit instructions +- "withdraw 50" or "withdraw all" to request a withdrawal`; + } + } +} diff --git a/src/whatsapp/userManager.ts b/src/whatsapp/userManager.ts new file mode 100644 index 0000000..8587c34 --- /dev/null +++ b/src/whatsapp/userManager.ts @@ -0,0 +1,129 @@ +import crypto from 'crypto'; +import { Keypair } from '@stellar/stellar-sdk'; +import db from '../db'; +import { Network } from '@prisma/client'; + +// simple stub encryption; in production plug in KMS (AWS/Google/etc) +function encryptSecret(secret: string): string { + // for demo purposes we just base64 encode; this is NOT secure + return Buffer.from(secret).toString('base64'); +} + +function decryptSecret(encrypted: string): string { + return Buffer.from(encrypted, 'base64').toString('utf8'); +} + +function generateOtpCode(): string { + const code = crypto.randomInt(0, 1000000).toString().padStart(6, '0'); + return code; +} + +const OTP_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes + +import { config } from '../config/env'; + +export interface WhatsAppUser { + id: string; + phoneNumber: string; + walletAddress: string; + walletSecretEncrypted?: string; + otpCode?: string; + otpExpiry?: Date; + isActive: boolean; + network: Network; // prisma Network enum +} + +/** + * Fetch a user by phone number; create a new record if none exists. + * New users are created with isActive=false and an OTP code set. + */ +export async function getOrCreateUser(phoneNumber: string): Promise { + let user = await db.user.findUnique({ where: { phoneNumber } }); + if (user) { + return user as unknown as WhatsAppUser; + } + + const pair = Keypair.random(); + const otp = generateOtpCode(); + const expiry = new Date(Date.now() + OTP_EXPIRY_MS); + + const created = await db.user.create({ + data: { + phoneNumber, + walletAddress: pair.publicKey(), + walletSecretEncrypted: encryptSecret(pair.secret()), + otpCode: otp, + otpExpiry: expiry, + isActive: false, + network: config.stellar.network.toUpperCase() as Network, + }, + }); + + return created as unknown as WhatsAppUser; +} + +/** + * Generate a fresh OTP for an existing user and update the record. + * Returns the raw code so the caller can send it. + */ +export async function generateAndSaveOtp(phoneNumber: string): Promise { + const code = generateOtpCode(); + const expiry = new Date(Date.now() + OTP_EXPIRY_MS); + await db.user.update({ + where: { phoneNumber }, + data: { otpCode: code, otpExpiry: expiry, isActive: false }, + }); + return code; +} + +/** + * Verify a code for the user and activate the account if valid. + */ +export async function verifyOtpCode(phoneNumber: string, code: string): Promise { + const user = await db.user.findUnique({ where: { phoneNumber } }); + if (!user || !user.otpCode || !user.otpExpiry) { + return false; + } + + if (user.otpExpiry < new Date()) { + return false; + } + if (user.otpCode !== code.trim()) { + return false; + } + + await db.user.update({ + where: { phoneNumber }, + data: { isActive: true, otpCode: null, otpExpiry: null }, + }); + return true; +} + +/** + * Convenience helper to retrieve a user after we know it exists. + */ +export async function getUserByPhone(phoneNumber: string): Promise { + const user = await db.user.findUnique({ where: { phoneNumber } }); + return user as unknown as WhatsAppUser | null; +} + +/** + * Helper to calculate approximate balance via positions (currentValue sum) + */ +export async function calculateBalance(phoneNumber: string): Promise { + const user = await db.user.findUnique({ + where: { phoneNumber }, + include: { positions: true }, + }); + if (!user) return 0; + const sum = user.positions.reduce((acc, pos) => acc + Number(pos.currentValue), 0); + return sum; +} + +export default { + getOrCreateUser, + generateAndSaveOtp, + verifyOtpCode, + getUserByPhone, + calculateBalance, +}; diff --git a/tests/integration/whatsapp.test.ts b/tests/integration/whatsapp.test.ts new file mode 100644 index 0000000..bfb7cef --- /dev/null +++ b/tests/integration/whatsapp.test.ts @@ -0,0 +1,194 @@ +// populate env vars used by src/config/env.ts before any imports +process.env.STELLAR_NETWORK = 'TESTNET'; +process.env.STELLAR_RPC_URL = 'http://localhost'; +process.env.STELLAR_AGENT_SECRET = 'SOMETHING'; +process.env.AGENT_SECRET_KEY = 'dummy'; +process.env.VAULT_CONTRACT_ID = 'vault'; +process.env.USDC_TOKEN_ADDRESS = 'usdc'; +process.env.ANTHROPIC_API_KEY = 'fake'; +process.env.DATABASE_URL = 'postgres://user:pass@localhost:5432/db'; +process.env.TWILIO_AUTH_TOKEN = 'test-token'; + +import request from 'supertest'; +import app from '../../src/index'; +import * as userManager from '../../src/whatsapp/userManager'; +import db from '../../src/db'; +import twilio from 'twilio'; + +jest.mock('../../src/whatsapp/userManager'); +jest.mock('../../src/db'); +jest.mock('twilio'); + +describe('WhatsApp webhook integration', () => { + const samplePhone = '+15551234567'; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('GET health check returns 200', async () => { + const resp = await request(app).get('/api/whatsapp/webhook'); + expect(resp.status).toBe(200); + expect(resp.text).toBe('OK'); + }); + + it('rejects invalid signature', async () => { + (twilio.validateRequest as jest.Mock).mockReturnValue(false); + const resp = await request(app) + .post('/api/whatsapp/webhook') + .type('form') + .send({ From: `whatsapp:${samplePhone}`, Body: 'foo' }); + expect(resp.status).toBe(403); + }); + + it('new user gets OTP message', async () => { + (twilio.validateRequest as jest.Mock).mockReturnValue(true); + // no existing user; getOrCreate will create and return isActive=false + (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + id: 'u1', + phoneNumber: samplePhone, + walletAddress: 'GABC', + isActive: false, + }); + (userManager.generateAndSaveOtp as jest.Mock).mockResolvedValue('123456'); + + const resp = await request(app) + .post('/api/whatsapp/webhook') + .type('form') + .send({ From: `whatsapp:${samplePhone}`, Body: 'hello' }); + + expect(resp.status).toBe(200); + expect(resp.text).toContain('123456'); + expect(userManager.generateAndSaveOtp).toHaveBeenCalledWith(samplePhone); + }); + + it('OTP verification works and returns active message', async () => { + (twilio.validateRequest as jest.Mock).mockReturnValue(true); + // simulate existing not active user + (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + id: 'u1', + phoneNumber: samplePhone, + walletAddress: 'GABC', + isActive: false, + }); + (userManager.verifyOtpCode as jest.Mock).mockResolvedValue(true); + + // send 6-digit code + const resp = await request(app) + .post('/api/whatsapp/webhook') + .type('form') + .send({ From: `whatsapp:${samplePhone}`, Body: '123456' }); + + expect(resp.status).toBe(200); + expect(resp.text).toContain('verified'); + }); + + it('wrong OTP triggers resend', async () => { + (twilio.validateRequest as jest.Mock).mockReturnValue(true); + (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + id: 'u1', + phoneNumber: samplePhone, + walletAddress: 'GABC', + isActive: false, + }); + (userManager.verifyOtpCode as jest.Mock).mockResolvedValue(false); + (userManager.generateAndSaveOtp as jest.Mock).mockResolvedValue('654321'); + + const resp = await request(app) + .post('/api/whatsapp/webhook') + .type('form') + .send({ From: `whatsapp:${samplePhone}`, Body: '000000' }); + + expect(resp.status).toBe(200); + expect(resp.text).toContain('new OTP'); + expect(resp.text).toContain('654321'); + }); + + it('balance query for active user', async () => { + (twilio.validateRequest as jest.Mock).mockReturnValue(true); + (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + id: 'u1', + phoneNumber: samplePhone, + walletAddress: 'GABC', + isActive: true, + network: 'TESTNET', + }); + (userManager.calculateBalance as jest.Mock).mockResolvedValue(42); + + const resp = await request(app) + .post('/api/whatsapp/webhook') + .type('form') + .send({ From: `whatsapp:${samplePhone}`, Body: 'balance' }); + + expect(resp.status).toBe(200); + expect(resp.text).toContain('42'); + }); + + it('deposit flow creates pending transaction', async () => { + (twilio.validateRequest as jest.Mock).mockReturnValue(true); + (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + id: 'u1', + phoneNumber: samplePhone, + walletAddress: 'GABC', + isActive: true, + network: 'TESTNET', + }); + + (db.transaction.create as jest.Mock).mockResolvedValue({}); + + const resp = await request(app) + .post('/api/whatsapp/webhook') + .type('form') + .send({ From: `whatsapp:${samplePhone}`, Body: 'deposit 100' }); + + expect(resp.status).toBe(200); + expect(resp.text).toMatch(/To deposit/); + expect(db.transaction.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ type: 'DEPOSIT', amount: 100 }), + })); + }); + + it('withdraw flow with insufficient balance returns message', async () => { + (twilio.validateRequest as jest.Mock).mockReturnValue(true); + (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + id: 'u1', + phoneNumber: samplePhone, + walletAddress: 'GABC', + isActive: true, + network: 'TESTNET', + }); + (userManager.calculateBalance as jest.Mock).mockResolvedValue(50); + + const resp = await request(app) + .post('/api/whatsapp/webhook') + .type('form') + .send({ From: `whatsapp:${samplePhone}`, Body: 'withdraw 100' }); + + expect(resp.status).toBe(200); + expect(resp.text).toMatch(/only have/); + }); + + it('withdraw all creates pending transaction', async () => { + (twilio.validateRequest as jest.Mock).mockReturnValue(true); + (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + id: 'u1', + phoneNumber: samplePhone, + walletAddress: 'GABC', + isActive: true, + network: 'TESTNET', + }); + (userManager.calculateBalance as jest.Mock).mockResolvedValue(123); + (db.transaction.create as jest.Mock).mockResolvedValue({}); + + const resp = await request(app) + .post('/api/whatsapp/webhook') + .type('form') + .send({ From: `whatsapp:${samplePhone}`, Body: 'withdraw all' }); + + expect(resp.status).toBe(200); + expect(resp.text).toMatch(/withdraw all/); + expect(db.transaction.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ type: 'WITHDRAWAL', amount: 123 }), + })); + }); +}); From 44052f23471cefa482b76fb3d10a470986c6360c Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 20:13:55 +0100 Subject: [PATCH 2/6] Refactor tests to use server instance instead of app --- tests/integration/whatsapp.test.ts | 48 ++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/tests/integration/whatsapp.test.ts b/tests/integration/whatsapp.test.ts index bfb7cef..91543a1 100644 --- a/tests/integration/whatsapp.test.ts +++ b/tests/integration/whatsapp.test.ts @@ -14,6 +14,7 @@ import app from '../../src/index'; import * as userManager from '../../src/whatsapp/userManager'; import db from '../../src/db'; import twilio from 'twilio'; +import { Server } from 'http'; jest.mock('../../src/whatsapp/userManager'); jest.mock('../../src/db'); @@ -21,20 +22,49 @@ jest.mock('twilio'); describe('WhatsApp webhook integration', () => { const samplePhone = '+15551234567'; + let server: Server; + + beforeAll((done) => { + server = app.listen(0, done); + }); + + afterAll(async () => { + // Close the HTTP server + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + + // Disconnect DB if supported + if (typeof (db as any).$disconnect === 'function') { + await (db as any).$disconnect(); + } else if (typeof (db as any).disconnect === 'function') { + await (db as any).disconnect(); + } else if (typeof (db as any).end === 'function') { + await (db as any).end(); + } + + // Allow any remaining async handles to settle + await new Promise((resolve) => setImmediate(resolve)); + }); beforeEach(() => { jest.resetAllMocks(); }); + afterEach(async () => { + // Flush any pending promises/microtasks between tests + await new Promise((resolve) => setImmediate(resolve)); + }); + it('GET health check returns 200', async () => { - const resp = await request(app).get('/api/whatsapp/webhook'); + const resp = await request(server).get('/api/whatsapp/webhook'); expect(resp.status).toBe(200); expect(resp.text).toBe('OK'); }); it('rejects invalid signature', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(false); - const resp = await request(app) + const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: 'foo' }); @@ -52,7 +82,7 @@ describe('WhatsApp webhook integration', () => { }); (userManager.generateAndSaveOtp as jest.Mock).mockResolvedValue('123456'); - const resp = await request(app) + const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: 'hello' }); @@ -74,7 +104,7 @@ describe('WhatsApp webhook integration', () => { (userManager.verifyOtpCode as jest.Mock).mockResolvedValue(true); // send 6-digit code - const resp = await request(app) + const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: '123456' }); @@ -94,7 +124,7 @@ describe('WhatsApp webhook integration', () => { (userManager.verifyOtpCode as jest.Mock).mockResolvedValue(false); (userManager.generateAndSaveOtp as jest.Mock).mockResolvedValue('654321'); - const resp = await request(app) + const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: '000000' }); @@ -115,7 +145,7 @@ describe('WhatsApp webhook integration', () => { }); (userManager.calculateBalance as jest.Mock).mockResolvedValue(42); - const resp = await request(app) + const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: 'balance' }); @@ -136,7 +166,7 @@ describe('WhatsApp webhook integration', () => { (db.transaction.create as jest.Mock).mockResolvedValue({}); - const resp = await request(app) + const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: 'deposit 100' }); @@ -159,7 +189,7 @@ describe('WhatsApp webhook integration', () => { }); (userManager.calculateBalance as jest.Mock).mockResolvedValue(50); - const resp = await request(app) + const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: 'withdraw 100' }); @@ -180,7 +210,7 @@ describe('WhatsApp webhook integration', () => { (userManager.calculateBalance as jest.Mock).mockResolvedValue(123); (db.transaction.create as jest.Mock).mockResolvedValue({}); - const resp = await request(app) + const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: 'withdraw all' }); From 0b578c62eea949292c0f80034294dff42768910c Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 20:20:03 +0100 Subject: [PATCH 3/6] check fix --- tests/integration/whatsapp.test.ts | 64 ++++++++++++++++-------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/tests/integration/whatsapp.test.ts b/tests/integration/whatsapp.test.ts index 91543a1..f509f94 100644 --- a/tests/integration/whatsapp.test.ts +++ b/tests/integration/whatsapp.test.ts @@ -12,14 +12,30 @@ process.env.TWILIO_AUTH_TOKEN = 'test-token'; import request from 'supertest'; import app from '../../src/index'; import * as userManager from '../../src/whatsapp/userManager'; -import db from '../../src/db'; import twilio from 'twilio'; import { Server } from 'http'; +// Mock db with the shape the app expects (Prisma-style client). +// Defined BEFORE jest.mock so the factory can reference it. +const mockDb = { + transaction: { + create: jest.fn(), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), +}; + jest.mock('../../src/whatsapp/userManager'); -jest.mock('../../src/db'); +jest.mock('../../src/db', () => mockDb); jest.mock('twilio'); +// Helper: TwiML responses look like: +// some text here +// Extract the inner text of the first tag. +function getTwimlMessage(responseText: string): string { + const match = responseText.match(/]*>([\s\S]*?)<\/Message>/i); + return match ? match[1].trim() : responseText; +} + describe('WhatsApp webhook integration', () => { const samplePhone = '+15551234567'; let server: Server; @@ -34,25 +50,18 @@ describe('WhatsApp webhook integration', () => { server.close((err) => (err ? reject(err) : resolve())); }); - // Disconnect DB if supported - if (typeof (db as any).$disconnect === 'function') { - await (db as any).$disconnect(); - } else if (typeof (db as any).disconnect === 'function') { - await (db as any).disconnect(); - } else if (typeof (db as any).end === 'function') { - await (db as any).end(); - } - - // Allow any remaining async handles to settle + // Drain any remaining async handles await new Promise((resolve) => setImmediate(resolve)); }); beforeEach(() => { jest.resetAllMocks(); + // Re-stub after resetAllMocks wipes mockDb's jest.fn() implementations + mockDb.$disconnect.mockResolvedValue(undefined); + mockDb.transaction.create.mockResolvedValue(undefined); }); afterEach(async () => { - // Flush any pending promises/microtasks between tests await new Promise((resolve) => setImmediate(resolve)); }); @@ -73,7 +82,6 @@ describe('WhatsApp webhook integration', () => { it('new user gets OTP message', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - // no existing user; getOrCreate will create and return isActive=false (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, @@ -88,13 +96,12 @@ describe('WhatsApp webhook integration', () => { .send({ From: `whatsapp:${samplePhone}`, Body: 'hello' }); expect(resp.status).toBe(200); - expect(resp.text).toContain('123456'); + expect(getTwimlMessage(resp.text)).toContain('123456'); expect(userManager.generateAndSaveOtp).toHaveBeenCalledWith(samplePhone); }); it('OTP verification works and returns active message', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - // simulate existing not active user (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, @@ -103,14 +110,13 @@ describe('WhatsApp webhook integration', () => { }); (userManager.verifyOtpCode as jest.Mock).mockResolvedValue(true); - // send 6-digit code const resp = await request(server) .post('/api/whatsapp/webhook') .type('form') .send({ From: `whatsapp:${samplePhone}`, Body: '123456' }); expect(resp.status).toBe(200); - expect(resp.text).toContain('verified'); + expect(getTwimlMessage(resp.text)).toContain('verified'); }); it('wrong OTP triggers resend', async () => { @@ -130,8 +136,9 @@ describe('WhatsApp webhook integration', () => { .send({ From: `whatsapp:${samplePhone}`, Body: '000000' }); expect(resp.status).toBe(200); - expect(resp.text).toContain('new OTP'); - expect(resp.text).toContain('654321'); + const msg = getTwimlMessage(resp.text); + expect(msg).toContain('new OTP'); + expect(msg).toContain('654321'); }); it('balance query for active user', async () => { @@ -151,7 +158,7 @@ describe('WhatsApp webhook integration', () => { .send({ From: `whatsapp:${samplePhone}`, Body: 'balance' }); expect(resp.status).toBe(200); - expect(resp.text).toContain('42'); + expect(getTwimlMessage(resp.text)).toContain('42'); }); it('deposit flow creates pending transaction', async () => { @@ -163,8 +170,7 @@ describe('WhatsApp webhook integration', () => { isActive: true, network: 'TESTNET', }); - - (db.transaction.create as jest.Mock).mockResolvedValue({}); + mockDb.transaction.create.mockResolvedValue({}); const resp = await request(server) .post('/api/whatsapp/webhook') @@ -172,8 +178,8 @@ describe('WhatsApp webhook integration', () => { .send({ From: `whatsapp:${samplePhone}`, Body: 'deposit 100' }); expect(resp.status).toBe(200); - expect(resp.text).toMatch(/To deposit/); - expect(db.transaction.create).toHaveBeenCalledWith(expect.objectContaining({ + expect(getTwimlMessage(resp.text)).toMatch(/To deposit/); + expect(mockDb.transaction.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ type: 'DEPOSIT', amount: 100 }), })); }); @@ -195,7 +201,7 @@ describe('WhatsApp webhook integration', () => { .send({ From: `whatsapp:${samplePhone}`, Body: 'withdraw 100' }); expect(resp.status).toBe(200); - expect(resp.text).toMatch(/only have/); + expect(getTwimlMessage(resp.text)).toMatch(/only have/); }); it('withdraw all creates pending transaction', async () => { @@ -208,7 +214,7 @@ describe('WhatsApp webhook integration', () => { network: 'TESTNET', }); (userManager.calculateBalance as jest.Mock).mockResolvedValue(123); - (db.transaction.create as jest.Mock).mockResolvedValue({}); + mockDb.transaction.create.mockResolvedValue({}); const resp = await request(server) .post('/api/whatsapp/webhook') @@ -216,8 +222,8 @@ describe('WhatsApp webhook integration', () => { .send({ From: `whatsapp:${samplePhone}`, Body: 'withdraw all' }); expect(resp.status).toBe(200); - expect(resp.text).toMatch(/withdraw all/); - expect(db.transaction.create).toHaveBeenCalledWith(expect.objectContaining({ + expect(getTwimlMessage(resp.text)).toMatch(/withdraw all/); + expect(mockDb.transaction.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ type: 'WITHDRAWAL', amount: 123 }), })); }); From 4f5e109666c337edaad842f58716b10619d36938 Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 20:20:43 +0100 Subject: [PATCH 4/6] Add newline at end of whatsapp.test.ts Fix missing newline at end of file in whatsapp.test.ts From d70faf01af25649b90d2d343680aef28b547a68f Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 20:25:13 +0100 Subject: [PATCH 5/6] Mock userManager in WhatsApp integration tests --- tests/integration/whatsapp.test.ts | 105 +++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/tests/integration/whatsapp.test.ts b/tests/integration/whatsapp.test.ts index f509f94..6af8790 100644 --- a/tests/integration/whatsapp.test.ts +++ b/tests/integration/whatsapp.test.ts @@ -11,31 +11,73 @@ process.env.TWILIO_AUTH_TOKEN = 'test-token'; import request from 'supertest'; import app from '../../src/index'; -import * as userManager from '../../src/whatsapp/userManager'; import twilio from 'twilio'; import { Server } from 'http'; -// Mock db with the shape the app expects (Prisma-style client). -// Defined BEFORE jest.mock so the factory can reference it. +// --------------------------------------------------------------------------- +// userManager mock +// +// src/whatsapp/userManager.ts has BOTH named exports AND a default export +// that is a plain object referencing those same functions. +// src/whatsapp/handler.ts imports the DEFAULT: +// import userManager from './userManager'; +// userManager.getOrCreateUser(...) +// +// To intercept the default-export calls we must mock the module so that the +// default export object's methods are jest.fn()s we can control. +// --------------------------------------------------------------------------- +const mockUserManager = { + getOrCreateUser: jest.fn(), + generateAndSaveOtp: jest.fn(), + verifyOtpCode: jest.fn(), + getUserByPhone: jest.fn(), + calculateBalance: jest.fn(), +}; + +jest.mock('../../src/whatsapp/userManager', () => ({ + // named exports (used nowhere in the handler, but keep parity) + getOrCreateUser: mockUserManager.getOrCreateUser, + generateAndSaveOtp: mockUserManager.generateAndSaveOtp, + verifyOtpCode: mockUserManager.verifyOtpCode, + getUserByPhone: mockUserManager.getUserByPhone, + calculateBalance: mockUserManager.calculateBalance, + // default export – this is what handler.ts actually calls + default: mockUserManager, + __esModule: true, +})); + +// --------------------------------------------------------------------------- +// db mock (Prisma-style client) +// --------------------------------------------------------------------------- const mockDb = { transaction: { create: jest.fn(), }, + user: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, $disconnect: jest.fn().mockResolvedValue(undefined), }; -jest.mock('../../src/whatsapp/userManager'); -jest.mock('../../src/db', () => mockDb); +jest.mock('../../src/db', () => ({ default: mockDb, __esModule: true })); + jest.mock('twilio'); +// --------------------------------------------------------------------------- // Helper: TwiML responses look like: -// some text here +// text here // Extract the inner text of the first tag. +// --------------------------------------------------------------------------- function getTwimlMessage(responseText: string): string { const match = responseText.match(/]*>([\s\S]*?)<\/Message>/i); return match ? match[1].trim() : responseText; } +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- describe('WhatsApp webhook integration', () => { const samplePhone = '+15551234567'; let server: Server; @@ -45,32 +87,34 @@ describe('WhatsApp webhook integration', () => { }); afterAll(async () => { - // Close the HTTP server await new Promise((resolve, reject) => { server.close((err) => (err ? reject(err) : resolve())); }); - - // Drain any remaining async handles await new Promise((resolve) => setImmediate(resolve)); }); beforeEach(() => { jest.resetAllMocks(); - // Re-stub after resetAllMocks wipes mockDb's jest.fn() implementations + // Restore default resolved values after resetAllMocks clears them mockDb.$disconnect.mockResolvedValue(undefined); - mockDb.transaction.create.mockResolvedValue(undefined); + mockDb.transaction.create.mockResolvedValue({}); + mockDb.user.findUnique.mockResolvedValue(null); + mockDb.user.create.mockResolvedValue({}); + mockDb.user.update.mockResolvedValue({}); }); afterEach(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + // ------------------------------------------------------------------------- it('GET health check returns 200', async () => { const resp = await request(server).get('/api/whatsapp/webhook'); expect(resp.status).toBe(200); expect(resp.text).toBe('OK'); }); + // ------------------------------------------------------------------------- it('rejects invalid signature', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(false); const resp = await request(server) @@ -80,15 +124,16 @@ describe('WhatsApp webhook integration', () => { expect(resp.status).toBe(403); }); + // ------------------------------------------------------------------------- it('new user gets OTP message', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + mockUserManager.getOrCreateUser.mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, walletAddress: 'GABC', isActive: false, }); - (userManager.generateAndSaveOtp as jest.Mock).mockResolvedValue('123456'); + mockUserManager.generateAndSaveOtp.mockResolvedValue('123456'); const resp = await request(server) .post('/api/whatsapp/webhook') @@ -97,18 +142,19 @@ describe('WhatsApp webhook integration', () => { expect(resp.status).toBe(200); expect(getTwimlMessage(resp.text)).toContain('123456'); - expect(userManager.generateAndSaveOtp).toHaveBeenCalledWith(samplePhone); + expect(mockUserManager.generateAndSaveOtp).toHaveBeenCalledWith(samplePhone); }); + // ------------------------------------------------------------------------- it('OTP verification works and returns active message', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + mockUserManager.getOrCreateUser.mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, walletAddress: 'GABC', isActive: false, }); - (userManager.verifyOtpCode as jest.Mock).mockResolvedValue(true); + mockUserManager.verifyOtpCode.mockResolvedValue(true); const resp = await request(server) .post('/api/whatsapp/webhook') @@ -119,16 +165,17 @@ describe('WhatsApp webhook integration', () => { expect(getTwimlMessage(resp.text)).toContain('verified'); }); + // ------------------------------------------------------------------------- it('wrong OTP triggers resend', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + mockUserManager.getOrCreateUser.mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, walletAddress: 'GABC', isActive: false, }); - (userManager.verifyOtpCode as jest.Mock).mockResolvedValue(false); - (userManager.generateAndSaveOtp as jest.Mock).mockResolvedValue('654321'); + mockUserManager.verifyOtpCode.mockResolvedValue(false); + mockUserManager.generateAndSaveOtp.mockResolvedValue('654321'); const resp = await request(server) .post('/api/whatsapp/webhook') @@ -141,16 +188,17 @@ describe('WhatsApp webhook integration', () => { expect(msg).toContain('654321'); }); + // ------------------------------------------------------------------------- it('balance query for active user', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + mockUserManager.getOrCreateUser.mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, walletAddress: 'GABC', isActive: true, network: 'TESTNET', }); - (userManager.calculateBalance as jest.Mock).mockResolvedValue(42); + mockUserManager.calculateBalance.mockResolvedValue(42); const resp = await request(server) .post('/api/whatsapp/webhook') @@ -161,16 +209,16 @@ describe('WhatsApp webhook integration', () => { expect(getTwimlMessage(resp.text)).toContain('42'); }); + // ------------------------------------------------------------------------- it('deposit flow creates pending transaction', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + mockUserManager.getOrCreateUser.mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, walletAddress: 'GABC', isActive: true, network: 'TESTNET', }); - mockDb.transaction.create.mockResolvedValue({}); const resp = await request(server) .post('/api/whatsapp/webhook') @@ -184,16 +232,17 @@ describe('WhatsApp webhook integration', () => { })); }); + // ------------------------------------------------------------------------- it('withdraw flow with insufficient balance returns message', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + mockUserManager.getOrCreateUser.mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, walletAddress: 'GABC', isActive: true, network: 'TESTNET', }); - (userManager.calculateBalance as jest.Mock).mockResolvedValue(50); + mockUserManager.calculateBalance.mockResolvedValue(50); const resp = await request(server) .post('/api/whatsapp/webhook') @@ -204,17 +253,17 @@ describe('WhatsApp webhook integration', () => { expect(getTwimlMessage(resp.text)).toMatch(/only have/); }); + // ------------------------------------------------------------------------- it('withdraw all creates pending transaction', async () => { (twilio.validateRequest as jest.Mock).mockReturnValue(true); - (userManager.getOrCreateUser as jest.Mock).mockResolvedValue({ + mockUserManager.getOrCreateUser.mockResolvedValue({ id: 'u1', phoneNumber: samplePhone, walletAddress: 'GABC', isActive: true, network: 'TESTNET', }); - (userManager.calculateBalance as jest.Mock).mockResolvedValue(123); - mockDb.transaction.create.mockResolvedValue({}); + mockUserManager.calculateBalance.mockResolvedValue(123); const resp = await request(server) .post('/api/whatsapp/webhook') From 7322024c8da1251fce7699a29543cd133cb69c33 Mon Sep 17 00:00:00 2001 From: Uyoxy Date: Fri, 6 Mar 2026 20:32:48 +0100 Subject: [PATCH 6/6] Add newline at end of whatsapp.test.ts Fix missing