diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8990b46..e1fe9ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,16 +14,21 @@ concurrency: jobs: check: - name: Lint, typecheck, build + name: Lint, typecheck, test, build runs-on: ubuntu-latest # Dummy env vars so `next build` doesn't fail when it reads the # public Supabase config at build time. These never leave CI and # never hit a real service — they just satisfy the `!` non-null - # assertions in the client factories. + # assertions in the client factories. ENCRYPTION_KEY and + # META_APP_SECRET are read at module-load by lib/whatsapp/*; the + # test suite asserts behaviour around these values, so any non- + # empty placeholder works as long as it stays consistent with + # vitest.config.ts. env: NEXT_PUBLIC_SUPABASE_URL: https://ci.example.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-dummy-anon-key ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' + META_APP_SECRET: 'ci-dummy-meta-secret' steps: - uses: actions/checkout@v6 @@ -41,5 +46,8 @@ jobs: - name: Typecheck run: npm run typecheck + - name: Test + run: npm test + - name: Build run: npm run build diff --git a/package-lock.json b/package-lock.json index 246a0fa..36aa52c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,8 @@ "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^4", - "typescript": "^6" + "typescript": "^6", + "vitest": "^4.1.6" }, "engines": { "node": ">=20.0.0" @@ -1996,6 +1997,299 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2021,6 +2315,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.105.4", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.4.tgz", @@ -2560,6 +2861,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3187,6 +3506,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.6", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3491,6 +3923,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -3756,6 +4198,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4532,6 +4984,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5028,6 +5487,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5094,6 +5563,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -5412,6 +5891,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "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, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7627,6 +8121,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -7881,6 +8386,13 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7919,9 +8431,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -8394,6 +8906,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8878,6 +9424,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8931,6 +9484,13 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -8940,6 +9500,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -9272,6 +9839,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -9320,6 +9904,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -9766,6 +10360,200 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -9879,6 +10667,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 2141e2e..ffcf181 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "lint": "eslint", "typecheck": "tsc --noEmit", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@base-ui/react": "^1.4.1", @@ -65,6 +67,7 @@ "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "tailwindcss": "^4", - "typescript": "^6" + "typescript": "^6", + "vitest": "^4.1.6" } } diff --git a/src/lib/automations/validate.test.ts b/src/lib/automations/validate.test.ts new file mode 100644 index 0000000..d7c54f5 --- /dev/null +++ b/src/lib/automations/validate.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { + validateStepsForActivation, + validateTriggerForActivation, +} from "./validate"; + +describe("validateStepsForActivation", () => { + it("rejects empty or missing step lists", () => { + expect(validateStepsForActivation([])).toEqual([ + { path: "steps", message: "active automations need at least one step" }, + ]); + expect( + validateStepsForActivation(undefined as unknown as never[]), + ).toEqual([ + { path: "steps", message: "active automations need at least one step" }, + ]); + }); + + it("passes a fully-populated step set", () => { + const issues = validateStepsForActivation([ + { step_type: "send_message", step_config: { text: "hi" } }, + { + step_type: "wait", + step_config: { amount: 5, unit: "minutes" }, + }, + { step_type: "add_tag", step_config: { tag_id: "tag-uuid" } }, + { step_type: "close_conversation", step_config: {} }, + ]); + expect(issues).toEqual([]); + }); + + it("flags every required field that is missing", () => { + const issues = validateStepsForActivation([ + { step_type: "send_message", step_config: { text: " " } }, + { step_type: "send_template", step_config: {} }, + { step_type: "add_tag", step_config: { tag_id: "" } }, + ]); + expect(issues.map((i) => i.path)).toEqual([ + "steps[0].text", + "steps[1].template_name", + "steps[2].tag_id", + ]); + }); + + it("checks wait amount and unit boundaries", () => { + const issues = validateStepsForActivation([ + { step_type: "wait", step_config: { amount: 0, unit: "minutes" } }, + { step_type: "wait", step_config: { amount: 5, unit: "seconds" } }, + { step_type: "wait", step_config: { amount: -1, unit: "hours" } }, + { + step_type: "wait", + step_config: { amount: Number.POSITIVE_INFINITY, unit: "days" }, + }, + ]); + expect(issues.map((i) => i.path)).toEqual([ + "steps[0].amount", + "steps[1].unit", + "steps[2].amount", + "steps[3].amount", + ]); + }); + + it("validates webhook URLs", () => { + const good = validateStepsForActivation([ + { + step_type: "send_webhook", + step_config: { url: "https://hooks.example.com/in" }, + }, + ]); + expect(good).toEqual([]); + + const noUrl = validateStepsForActivation([ + { step_type: "send_webhook", step_config: {} }, + ]); + expect(noUrl.map((i) => i.message)).toContain("webhook URL is required"); + + const wrongProtocol = validateStepsForActivation([ + { + step_type: "send_webhook", + step_config: { url: "ftp://files.example.com" }, + }, + ]); + expect(wrongProtocol.map((i) => i.message)).toContain( + "webhook URL must use http or https", + ); + + const garbage = validateStepsForActivation([ + { step_type: "send_webhook", step_config: { url: "not a url" } }, + ]); + expect(garbage.map((i) => i.message)).toContain( + "webhook URL is not a valid URL", + ); + }); + + it("validates assign_conversation only when mode is 'specific'", () => { + const roundRobinNoAgent = validateStepsForActivation([ + { + step_type: "assign_conversation", + step_config: { mode: "round_robin" }, + }, + ]); + expect(roundRobinNoAgent).toEqual([]); + + const specificMissingAgent = validateStepsForActivation([ + { step_type: "assign_conversation", step_config: { mode: "specific" } }, + ]); + expect(specificMissingAgent.map((i) => i.path)).toEqual([ + "steps[0].agent_id", + ]); + }); + + it("flags create_deal when required fields are missing", () => { + const issues = validateStepsForActivation([ + { step_type: "create_deal", step_config: {} }, + ]); + expect(issues.map((i) => i.path).sort()).toEqual([ + "steps[0].pipeline_id", + "steps[0].stage_id", + "steps[0].title", + ]); + }); + + it("flags update_contact_field when field or value is missing", () => { + const issues = validateStepsForActivation([ + { step_type: "update_contact_field", step_config: { field: "name" } }, + { + step_type: "update_contact_field", + step_config: { field: "", value: "x" }, + }, + ]); + expect(issues.map((i) => i.path)).toEqual([ + "steps[0].value", + "steps[1].field", + ]); + }); + + it("recursively walks condition branches with stable dot-paths", () => { + const issues = validateStepsForActivation([ + { + step_type: "condition", + step_config: { subject: "tag", operand: "vip" }, + branches: { + yes: [{ step_type: "add_tag", step_config: { tag_id: "" } }], + no: [ + { + step_type: "send_message", + step_config: { text: "" }, + }, + ], + }, + }, + ]); + expect(issues.map((i) => i.path)).toEqual([ + "steps[0].yes.steps[0].tag_id", + "steps[0].no.steps[0].text", + ]); + }); + + it("reports an issue for unknown step types", () => { + const issues = validateStepsForActivation([ + { step_type: "do_a_barrel_roll", step_config: {} }, + ]); + expect(issues).toEqual([ + { path: "steps[0]", message: "unknown step type: do_a_barrel_roll" }, + ]); + }); + + it("flags condition subject/operand independently", () => { + const issues = validateStepsForActivation([ + { step_type: "condition", step_config: {} }, + ]); + expect(issues.map((i) => i.path).sort()).toEqual([ + "steps[0].operand", + "steps[0].subject", + ]); + }); +}); + +describe("validateTriggerForActivation", () => { + it("accepts a valid keyword_match config", () => { + expect( + validateTriggerForActivation("keyword_match", { + keywords: ["hello", "hi"], + match_type: "exact", + }), + ).toEqual([]); + }); + + it("rejects keyword_match with empty keyword array", () => { + const issues = validateTriggerForActivation("keyword_match", { + keywords: [], + match_type: "exact", + }); + expect(issues.map((i) => i.path)).toContain("trigger.keywords"); + }); + + it("rejects keyword_match with whitespace-only entries", () => { + const issues = validateTriggerForActivation("keyword_match", { + keywords: ["hi", " "], + match_type: "contains", + }); + expect(issues.map((i) => i.message)).toContain( + "keywords cannot be empty strings", + ); + }); + + it("rejects keyword_match with an unknown match_type", () => { + const issues = validateTriggerForActivation("keyword_match", { + keywords: ["hi"], + match_type: "fuzzy", + }); + expect(issues.map((i) => i.path)).toContain("trigger.match_type"); + }); + + it("requires schedule on time_based triggers", () => { + expect(validateTriggerForActivation("time_based", {})).toEqual([ + { path: "trigger.schedule", message: "schedule is required" }, + ]); + expect( + validateTriggerForActivation("time_based", { schedule: "0 9 * * *" }), + ).toEqual([]); + }); + + it("requires tag_id on tag_added triggers", () => { + expect(validateTriggerForActivation("tag_added", {})).toEqual([ + { path: "trigger.tag_id", message: "tag is required" }, + ]); + expect( + validateTriggerForActivation("tag_added", { tag_id: "tag-uuid" }), + ).toEqual([]); + }); + + it("does not flag unknown trigger types (handled elsewhere)", () => { + expect(validateTriggerForActivation("some_future_trigger", {})).toEqual([]); + }); +}); diff --git a/src/lib/broadcast-status.test.ts b/src/lib/broadcast-status.test.ts new file mode 100644 index 0000000..9d44ed9 --- /dev/null +++ b/src/lib/broadcast-status.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + broadcastStatusConfig, + getBroadcastStatus, + getRecipientStatus, + recipientStatusConfig, +} from "./broadcast-status"; + +describe("getBroadcastStatus", () => { + it("returns the matching config for known statuses", () => { + expect(getBroadcastStatus("sending")).toBe(broadcastStatusConfig.sending); + expect(getBroadcastStatus("sent")).toBe(broadcastStatusConfig.sent); + expect(getBroadcastStatus("failed")).toBe(broadcastStatusConfig.failed); + }); + + it("flags `sending` as a live/pulsing state", () => { + expect(getBroadcastStatus("sending").pulse).toBe(true); + expect(getBroadcastStatus("sent").pulse).toBeFalsy(); + }); + + it("falls back to draft on an unknown status string", () => { + expect(getBroadcastStatus("not-a-real-status")).toBe( + broadcastStatusConfig.draft, + ); + expect(getBroadcastStatus("")).toBe(broadcastStatusConfig.draft); + }); + + it("each variant has the dark-theme class triple", () => { + for (const v of Object.values(broadcastStatusConfig)) { + expect(v.classes).toMatch(/bg-[a-z]+-\d+\/10/); + expect(v.classes).toMatch(/text-[a-z]+-\d+/); + expect(v.classes).toMatch(/border-[a-z]+-\d+\/20/); + } + }); +}); + +describe("getRecipientStatus", () => { + it("returns the matching config for known statuses", () => { + expect(getRecipientStatus("delivered")).toBe( + recipientStatusConfig.delivered, + ); + expect(getRecipientStatus("read")).toBe(recipientStatusConfig.read); + }); + + it("falls back to pending on an unknown status string", () => { + expect(getRecipientStatus("???")).toBe(recipientStatusConfig.pending); + }); +}); diff --git a/src/lib/dashboard/date-utils.test.ts b/src/lib/dashboard/date-utils.test.ts new file mode 100644 index 0000000..262135f --- /dev/null +++ b/src/lib/dashboard/date-utils.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + DOW_SHORT_MON_FIRST, + daysAgoStart, + lastNDayKeys, + localDayKey, + mondayIndex, + startOfLocalDay, +} from "./date-utils"; + +describe("startOfLocalDay", () => { + it("zeroes out the time of a given date", () => { + const d = new Date("2026-05-18T13:45:22.500"); + const out = startOfLocalDay(d); + expect(out.getHours()).toBe(0); + expect(out.getMinutes()).toBe(0); + expect(out.getSeconds()).toBe(0); + expect(out.getMilliseconds()).toBe(0); + expect(out.getFullYear()).toBe(d.getFullYear()); + expect(out.getMonth()).toBe(d.getMonth()); + expect(out.getDate()).toBe(d.getDate()); + }); + + it("does not mutate the input", () => { + const d = new Date("2026-05-18T13:45:22.500"); + const before = d.getTime(); + startOfLocalDay(d); + expect(d.getTime()).toBe(before); + }); +}); + +describe("daysAgoStart", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-18T13:45:22")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns midnight N days before today", () => { + const out = daysAgoStart(3); + expect(out.getHours()).toBe(0); + expect(out.getDate()).toBe(15); + expect(out.getMonth()).toBe(4); // May + expect(out.getFullYear()).toBe(2026); + }); + + it("daysAgoStart(0) is today at midnight", () => { + const out = daysAgoStart(0); + expect(out.getDate()).toBe(18); + expect(out.getHours()).toBe(0); + }); + + it("crosses month boundaries cleanly", () => { + vi.setSystemTime(new Date("2026-05-02T08:00:00")); + const out = daysAgoStart(5); + expect(out.getMonth()).toBe(3); // April (0-indexed) + expect(out.getDate()).toBe(27); + }); +}); + +describe("localDayKey", () => { + it("emits YYYY-MM-DD in local components", () => { + const d = new Date(2026, 0, 9, 23, 59); // Jan 9, locally + expect(localDayKey(d)).toBe("2026-01-09"); + }); + + it("zero-pads month and day", () => { + const d = new Date(2026, 8, 5); // Sep 5 + expect(localDayKey(d)).toBe("2026-09-05"); + }); + + it("accepts ISO strings as input", () => { + expect(localDayKey("2026-12-31T23:00:00")).toBe("2026-12-31"); + }); +}); + +describe("lastNDayKeys", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-18T08:30:00")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns n consecutive chronological keys ending today", () => { + expect(lastNDayKeys(3)).toEqual(["2026-05-16", "2026-05-17", "2026-05-18"]); + }); + + it("returns just today for n=1", () => { + expect(lastNDayKeys(1)).toEqual(["2026-05-18"]); + }); + + it("rolls back across a month boundary", () => { + vi.setSystemTime(new Date("2026-05-02T08:00:00")); + expect(lastNDayKeys(4)).toEqual([ + "2026-04-29", + "2026-04-30", + "2026-05-01", + "2026-05-02", + ]); + }); +}); + +describe("mondayIndex", () => { + it("maps Monday → 0 and Sunday → 6", () => { + expect(mondayIndex(new Date("2026-05-18"))).toBe(0); // Mon + expect(mondayIndex(new Date("2026-05-19"))).toBe(1); // Tue + expect(mondayIndex(new Date("2026-05-23"))).toBe(5); // Sat + expect(mondayIndex(new Date("2026-05-24"))).toBe(6); // Sun + }); + + it("aligns with DOW_SHORT_MON_FIRST labels", () => { + expect(DOW_SHORT_MON_FIRST[mondayIndex(new Date("2026-05-18"))]).toBe( + "Mon", + ); + expect(DOW_SHORT_MON_FIRST[mondayIndex(new Date("2026-05-24"))]).toBe( + "Sun", + ); + }); +}); diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..f0bc36d --- /dev/null +++ b/src/lib/rate-limit.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + __resetRateLimitForTests, + checkRateLimit, + rateLimitResponse, +} from "./rate-limit"; + +const OPTS = { limit: 3, windowMs: 60_000 }; + +describe("checkRateLimit", () => { + beforeEach(() => { + __resetRateLimitForTests(); + }); + + it("permits the first request and decrements remaining", () => { + const result = checkRateLimit("user:1", OPTS); + expect(result).toMatchObject({ + success: true, + remaining: 2, + limit: 3, + }); + expect(result.reset).toBeGreaterThan(Date.now()); + }); + + it("permits exactly `limit` requests then rejects the next", () => { + expect(checkRateLimit("user:1", OPTS).success).toBe(true); + expect(checkRateLimit("user:1", OPTS).success).toBe(true); + expect(checkRateLimit("user:1", OPTS).success).toBe(true); + const over = checkRateLimit("user:1", OPTS); + expect(over.success).toBe(false); + expect(over.remaining).toBe(0); + }); + + it("keeps separate counters per key", () => { + checkRateLimit("user:1", OPTS); + checkRateLimit("user:1", OPTS); + checkRateLimit("user:1", OPTS); + // user:1 is at the cap, user:2 should still be unaffected. + const other = checkRateLimit("user:2", OPTS); + expect(other.success).toBe(true); + expect(other.remaining).toBe(2); + }); + + it("opens a fresh window after `windowMs` elapses", () => { + vi.useFakeTimers(); + try { + const t0 = new Date("2026-05-01T00:00:00Z").getTime(); + vi.setSystemTime(t0); + __resetRateLimitForTests(); + + checkRateLimit("user:1", OPTS); + checkRateLimit("user:1", OPTS); + checkRateLimit("user:1", OPTS); + expect(checkRateLimit("user:1", OPTS).success).toBe(false); + + // Jump just past the window. + vi.setSystemTime(t0 + OPTS.windowMs + 1); + const refreshed = checkRateLimit("user:1", OPTS); + expect(refreshed.success).toBe(true); + expect(refreshed.remaining).toBe(2); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("rateLimitResponse", () => { + it("returns a 429 with retry / X-RateLimit headers", async () => { + const reset = Date.now() + 30_000; + const res = rateLimitResponse({ + success: false, + remaining: 0, + reset, + limit: 60, + }); + expect(res.status).toBe(429); + expect(res.headers.get("X-RateLimit-Limit")).toBe("60"); + expect(res.headers.get("X-RateLimit-Remaining")).toBe("0"); + expect(Number(res.headers.get("Retry-After"))).toBeGreaterThan(0); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/rate limit/i); + }); + + it("clamps Retry-After to a minimum of 1 second", () => { + // Reset already in the past — the ceiling math would otherwise give 0. + const res = rateLimitResponse({ + success: false, + remaining: 0, + reset: Date.now() - 5_000, + limit: 10, + }); + expect(Number(res.headers.get("Retry-After"))).toBeGreaterThanOrEqual(1); + }); +}); + +describe("RATE_LIMITS presets", () => { + it("send and broadcast budgets are independent", async () => { + __resetRateLimitForTests(); + // Importing here so the presets stay close to their assertions. + const { RATE_LIMITS } = await import("./rate-limit"); + expect(RATE_LIMITS.send.limit).toBeGreaterThan(RATE_LIMITS.broadcast.limit); + expect(RATE_LIMITS.send.windowMs).toBe(60_000); + expect(RATE_LIMITS.broadcast.windowMs).toBe(60_000); + }); +}); + +afterEach(() => { + __resetRateLimitForTests(); +}); diff --git a/src/lib/whatsapp/encryption.test.ts b/src/lib/whatsapp/encryption.test.ts new file mode 100644 index 0000000..7319b05 --- /dev/null +++ b/src/lib/whatsapp/encryption.test.ts @@ -0,0 +1,125 @@ +import crypto from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { decrypt, encrypt, isLegacyFormat } from "./encryption"; + +const KEY_HEX = process.env.ENCRYPTION_KEY!; + +function cbcEncryptLegacy(plaintext: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv( + "aes-256-cbc", + Buffer.from(KEY_HEX, "hex"), + iv, + ); + let ct = cipher.update(plaintext, "utf8", "hex"); + ct += cipher.final("hex"); + return `${iv.toString("hex")}:${ct}`; +} + +describe("encryption", () => { + describe("encrypt / decrypt round-trip", () => { + it("recovers the original plaintext", () => { + const ct = encrypt("EAAG... fake WhatsApp token"); + expect(decrypt(ct)).toBe("EAAG... fake WhatsApp token"); + }); + + it("produces three colon-separated parts (GCM)", () => { + const ct = encrypt("anything"); + expect(ct.split(":")).toHaveLength(3); + }); + + it("uses a fresh IV per encrypt so identical plaintexts produce different ciphertexts", () => { + const a = encrypt("same input"); + const b = encrypt("same input"); + expect(a).not.toBe(b); + expect(decrypt(a)).toBe("same input"); + expect(decrypt(b)).toBe("same input"); + }); + + it("roundtrips empty string", () => { + const ct = encrypt(""); + expect(decrypt(ct)).toBe(""); + }); + + it("roundtrips multibyte UTF-8", () => { + const ct = encrypt("token-✓-🔐-žąsis"); + expect(decrypt(ct)).toBe("token-✓-🔐-žąsis"); + }); + }); + + describe("GCM authentication", () => { + it("rejects ciphertext tampered after encryption", () => { + const ct = encrypt("secret"); + const [ivHex, ctHex, tagHex] = ct.split(":"); + // Flip a byte in the ciphertext body — auth tag will mismatch. + const tamperedCtHex = + (parseInt(ctHex.slice(0, 2), 16) ^ 0xff).toString(16).padStart(2, "0") + + ctHex.slice(2); + expect(() => + decrypt(`${ivHex}:${tamperedCtHex}:${tagHex}`), + ).toThrow(); + }); + + it("rejects a swapped auth tag", () => { + const ct = encrypt("secret"); + const [ivHex, ctHex] = ct.split(":"); + const bogusTag = "00".repeat(16); + expect(() => decrypt(`${ivHex}:${ctHex}:${bogusTag}`)).toThrow(); + }); + + it("rejects a GCM IV of the wrong length", () => { + const ct = encrypt("secret"); + const [, ctHex, tagHex] = ct.split(":"); + const shortIv = "00".repeat(8); // 8 bytes ≠ 12 + expect(() => decrypt(`${shortIv}:${ctHex}:${tagHex}`)).toThrow( + /GCM IV length/, + ); + }); + + it("rejects a GCM auth tag of the wrong length", () => { + const ct = encrypt("secret"); + const [ivHex, ctHex] = ct.split(":"); + const shortTag = "00".repeat(8); // 8 bytes ≠ 16 + expect(() => decrypt(`${ivHex}:${ctHex}:${shortTag}`)).toThrow( + /auth-tag length/, + ); + }); + }); + + describe("legacy CBC compatibility (read-only)", () => { + it("decrypts a CBC blob produced by the previous codepath", () => { + const legacy = cbcEncryptLegacy("old-token"); + expect(decrypt(legacy)).toBe("old-token"); + }); + + it("rejects a CBC blob with the wrong IV length", () => { + // 8-byte IV (16 hex chars) instead of 16 bytes. + const bogus = "00".repeat(8) + ":" + "00".repeat(16); + expect(() => decrypt(bogus)).toThrow(/CBC IV length/); + }); + }); + + describe("format detection", () => { + it("isLegacyFormat returns true for two-part CBC strings", () => { + const legacy = cbcEncryptLegacy("anything"); + expect(isLegacyFormat(legacy)).toBe(true); + }); + + it("isLegacyFormat returns false for three-part GCM strings", () => { + const modern = encrypt("anything"); + expect(isLegacyFormat(modern)).toBe(false); + }); + }); + + describe("malformed input", () => { + it("throws on a single-token blob (no colons)", () => { + expect(() => decrypt("not-encrypted-at-all")).toThrow( + /unrecognised format/, + ); + }); + + it("throws on a four-part blob", () => { + expect(() => decrypt("aa:bb:cc:dd")).toThrow(/unrecognised format/); + }); + }); +}); diff --git a/src/lib/whatsapp/phone-utils.test.ts b/src/lib/whatsapp/phone-utils.test.ts new file mode 100644 index 0000000..9d3a292 --- /dev/null +++ b/src/lib/whatsapp/phone-utils.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import { + isRecipientNotAllowedError, + isValidE164, + normalizePhone, + phoneVariants, + phonesMatch, + sanitizePhoneForMeta, +} from "./phone-utils"; + +describe("sanitizePhoneForMeta", () => { + it("strips +, spaces, and dashes leaving only digits", () => { + expect(sanitizePhoneForMeta("+370 639 49836")).toBe("37063949836"); + expect(sanitizePhoneForMeta("+1 (415) 555-1212")).toBe("14155551212"); + }); + + it("returns an empty string for falsy input", () => { + expect(sanitizePhoneForMeta("")).toBe(""); + // Defensive: existing call sites occasionally pass through nullable + // contact phones. The function early-returns on the falsy check. + expect(sanitizePhoneForMeta(undefined as unknown as string)).toBe(""); + }); + + it("is idempotent on already-sanitized input", () => { + const cleaned = "14155551212"; + expect(sanitizePhoneForMeta(cleaned)).toBe(cleaned); + }); +}); + +describe("normalizePhone", () => { + it("matches sanitizePhoneForMeta byte-for-byte (shared canonical form)", () => { + const samples = ["+370 12345", "abc-555-DEF", "", "0044 7000 0000 0000"]; + for (const s of samples) { + expect(normalizePhone(s)).toBe(sanitizePhoneForMeta(s)); + } + }); +}); + +describe("phonesMatch", () => { + it("returns true for exact digit matches", () => { + expect(phonesMatch("+37063949836", "37063949836")).toBe(true); + }); + + it("matches across trunk-prefix variants by last-8 fallback", () => { + // Lithuanian trunk-0 variant. Last 8 digits ("63949836") collide. + expect(phonesMatch("370063949836", "37063949836")).toBe(true); + }); + + it("rejects mismatched numbers", () => { + expect(phonesMatch("+37063949836", "+37063949837")).toBe(false); + }); + + it("rejects very short inputs that would false-positive on tail match", () => { + // Only 7 digits — the last-8 fallback is gated to len>=8 on both + // sides to avoid declaring "12345" and "67890-12345" a match. + expect(phonesMatch("1234567", "1234567")).toBe(true); + expect(phonesMatch("1234567", "9991234567")).toBe(false); + }); + + it("ignores formatting noise on both sides", () => { + expect(phonesMatch("+370 6 394 9836", "37063949836")).toBe(true); + expect(phonesMatch("(415) 555-1212", "+1 415-555-1212")).toBe(true); + }); +}); + +describe("isValidE164", () => { + it("accepts numbers 7–15 digits with optional + and non-zero start", () => { + expect(isValidE164("+37063949836")).toBe(true); + expect(isValidE164("37063949836")).toBe(true); + expect(isValidE164("+1234567")).toBe(true); // 7 digits — lower bound + expect(isValidE164("+123456789012345")).toBe(true); // 15 digits — upper bound + }); + + it("rejects numbers that start with 0 in international form", () => { + expect(isValidE164("+0123456")).toBe(false); + expect(isValidE164("0044700000000")).toBe(false); + }); + + it("rejects too-short and too-long inputs", () => { + expect(isValidE164("+123456")).toBe(false); // 6 digits + expect(isValidE164("+1234567890123456")).toBe(false); // 16 digits + }); + + it("rejects strings with non-digit characters", () => { + expect(isValidE164("+1-415-555-1212")).toBe(false); + expect(isValidE164("+1 4155551212")).toBe(false); + expect(isValidE164("abc12345678")).toBe(false); + }); + + it("rejects the empty string", () => { + expect(isValidE164("")).toBe(false); + }); +}); + +describe("phoneVariants", () => { + it("returns an empty list for empty input", () => { + expect(phoneVariants("")).toEqual([]); + }); + + it("always lists the original number first", () => { + const out = phoneVariants("37063949836"); + expect(out[0]).toBe("37063949836"); + }); + + it("inserts a trunk 0 after each plausible country-code length", () => { + // Input "37063949836" — CC-1 → "3" + "0" + "7063949836", + // CC-3 → "370" + "0" + "63949836". + // CC-2 is skipped because "063949836" already starts with 0. + const out = phoneVariants("37063949836"); + expect(out).toEqual( + expect.arrayContaining([ + "37063949836", + "307063949836", + "370063949836", + ]), + ); + }); + + it("removes a leading 0 after the country code when present", () => { + // Input "370063949836" — CC-2 strips one leading 0 from + // "0063949836" → "37" + "063949836" = "37063949836". Only one zero + // comes off per pass; that's what the live retry loop needs. + const out = phoneVariants("370063949836"); + expect(out).toContain("370063949836"); + expect(out).toContain("37063949836"); + }); + + it("deduplicates variants that collapse to the same digits", () => { + const out = phoneVariants("37063949836"); + expect(new Set(out).size).toBe(out.length); + }); + + it("returns just the original when the number is too short for any CC slice", () => { + // 1-char input is shorter than all ccLen values; both loops skip. + expect(phoneVariants("1")).toEqual(["1"]); + }); +}); + +describe("isRecipientNotAllowedError", () => { + it("matches Meta error code 131030", () => { + expect( + isRecipientNotAllowedError( + "(#131030) Recipient phone number not in allowed list", + ), + ).toBe(true); + }); + + it("matches the human-readable English variants", () => { + expect(isRecipientNotAllowedError("not in allowed list")).toBe(true); + expect(isRecipientNotAllowedError("recipient not in the allowed list")).toBe( + true, + ); + // Case-insensitive on the human text. + expect(isRecipientNotAllowedError("NOT IN ALLOWED LIST")).toBe(true); + }); + + it("does not false-positive on unrelated Meta errors", () => { + expect(isRecipientNotAllowedError("(#100) Invalid parameter")).toBe(false); + expect(isRecipientNotAllowedError("template name does not exist")).toBe( + false, + ); + expect(isRecipientNotAllowedError("")).toBe(false); + }); +}); diff --git a/src/lib/whatsapp/webhook-signature.test.ts b/src/lib/whatsapp/webhook-signature.test.ts new file mode 100644 index 0000000..be0d6c0 --- /dev/null +++ b/src/lib/whatsapp/webhook-signature.test.ts @@ -0,0 +1,69 @@ +import crypto from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { verifyMetaWebhookSignature } from "./webhook-signature"; + +const SECRET = process.env.META_APP_SECRET!; + +function signedHeader(body: string, secret: string = SECRET): string { + const hex = crypto.createHmac("sha256", secret).update(body).digest("hex"); + return `sha256=${hex}`; +} + +describe("verifyMetaWebhookSignature", () => { + it("accepts a request signed with the correct secret", () => { + const body = JSON.stringify({ object: "whatsapp_business_account" }); + expect(verifyMetaWebhookSignature(body, signedHeader(body))).toBe(true); + }); + + it("rejects a signature computed with a different secret", () => { + const body = "{}"; + expect(verifyMetaWebhookSignature(body, signedHeader(body, "wrong"))).toBe( + false, + ); + }); + + it("rejects when the body has been tampered with after signing", () => { + const original = '{"entry":[]}'; + const header = signedHeader(original); + const tampered = '{"entry":[{"id":"injected"}]}'; + expect(verifyMetaWebhookSignature(tampered, header)).toBe(false); + }); + + it("rejects a missing header", () => { + expect(verifyMetaWebhookSignature("anything", null)).toBe(false); + }); + + it("rejects a header without the sha256= prefix", () => { + const body = "{}"; + const hex = crypto + .createHmac("sha256", SECRET) + .update(body) + .digest("hex"); + expect(verifyMetaWebhookSignature(body, hex)).toBe(false); + expect(verifyMetaWebhookSignature(body, `sha512=${hex}`)).toBe(false); + }); + + it("rejects a header of the wrong length without throwing", () => { + // timingSafeEqual would throw on length mismatch — the guard inside + // the verifier should catch this and return false instead. + expect(verifyMetaWebhookSignature("{}", "sha256=tooshort")).toBe(false); + }); + + describe("fail-closed when secret is missing", () => { + const originalSecret = process.env.META_APP_SECRET; + beforeEach(() => { + delete process.env.META_APP_SECRET; + }); + afterEach(() => { + process.env.META_APP_SECRET = originalSecret; + }); + + it("rejects even a correctly-formed signature when no secret is configured", () => { + const body = "{}"; + // Use the original secret to produce the header so we can verify + // the rejection is solely due to missing config. + const header = signedHeader(body, originalSecret!); + expect(verifyMetaWebhookSignature(body, header)).toBe(false); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..563a78e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + // Dummy secrets — encryption.ts / webhook-signature.ts read these + // at module load. Tests never hit a real Meta/Supabase service, so + // any 32-byte hex / non-empty string will do; keep them lexically + // identical to the CI build env so behaviour matches. + env: { + ENCRYPTION_KEY: + "0000000000000000000000000000000000000000000000000000000000000000", + META_APP_SECRET: "test-meta-app-secret", + }, + clearMocks: true, + }, +});