mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
test: foundational Vitest suite for security + critical pure logic
Adds the first test layer to the repo. Vitest is wired into the existing CI workflow between typecheck and build so every PR gets the same gating before merge. No source code under test was modified; this PR only adds the test framework + tests. Coverage focuses on the highest-impact pure modules — security-critical crypto/HMAC, the phone-number pipeline every outbound message touches, the rate-limiter that protects send/broadcast, automation activation validation, and dashboard date math. 89 tests across 7 files; full run is ~300ms. Modules covered: - lib/whatsapp/encryption — GCM round-trip, tamper detection (auth tag, ciphertext, IV length, auth-tag length), legacy CBC compat, format detection, malformed input - lib/whatsapp/webhook-signature — valid/invalid HMAC, missing header, wrong prefix, length-mismatch guard, fail-closed when secret unset - lib/whatsapp/phone-utils — sanitize, normalize, phonesMatch with trunk-prefix tail fallback, isValidE164 boundaries (7–15 digits, non-zero start), phoneVariants insert/remove with dedupe, isRecipientNotAllowedError detection - lib/rate-limit — first-request math, exact-limit boundary, per-key isolation, window expiry with fake timers, 429 response shape, Retry-After clamp - lib/automations/validate — every step type's required fields, wait amount/unit boundaries, webhook URL validation (missing / non-http / malformed), assign_conversation mode branching, recursive condition walking with stable dot-paths, unknown step types - lib/broadcast-status — known status lookups, pulse flag, dark-theme class shape, fallback for unknown strings - lib/dashboard/date-utils — startOfLocalDay, daysAgoStart, localDayKey zero-padding, lastNDayKeys (1, N, month crossing), Monday-first DOW Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
149a4e67ef
commit
db11f76226
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
813
package-lock.json
generated
813
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
236
src/lib/automations/validate.test.ts
Normal file
236
src/lib/automations/validate.test.ts
Normal file
@ -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([]);
|
||||
});
|
||||
});
|
||||
48
src/lib/broadcast-status.test.ts
Normal file
48
src/lib/broadcast-status.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
123
src/lib/dashboard/date-utils.test.ts
Normal file
123
src/lib/dashboard/date-utils.test.ts
Normal file
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
109
src/lib/rate-limit.test.ts
Normal file
109
src/lib/rate-limit.test.ts
Normal file
@ -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();
|
||||
});
|
||||
125
src/lib/whatsapp/encryption.test.ts
Normal file
125
src/lib/whatsapp/encryption.test.ts
Normal file
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
164
src/lib/whatsapp/phone-utils.test.ts
Normal file
164
src/lib/whatsapp/phone-utils.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
69
src/lib/whatsapp/webhook-signature.test.ts
Normal file
69
src/lib/whatsapp/webhook-signature.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
vitest.config.ts
Normal file
21
vitest.config.ts
Normal file
@ -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,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user