diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..41abca1 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,25 @@ +name: Unit Tests + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test -- --run diff --git a/.gitignore b/.gitignore index 867d95d..4c4a60c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ scripts.ts # .md files dev-journal.md +CLAUDE.md + +# docs +docs/ diff --git a/package-lock.json b/package-lock.json index 8af1a39..5a74967 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,12 +43,14 @@ "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^8.6.0", "@typescript-eslint/parser": "^8.6.0", + "@vitest/coverage-v8": "^1.6.1", "eslint": "^9.11.0", "eslint-config-prettier": "^9.1.0", "prettier": "^3.3.3", "tsup": "^8.2.4", "tsx": "^4.7.1", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vitest": "^1.6.0" }, "engines": { "node": "23.x" @@ -60,6 +62,77 @@ "integrity": "sha512-/3DDPKHqqIqxUULp8yP4zODUY1i+2xvVWsv8A79xGWdCAG+8sb0hRh0Rk2QyOJUnnbyPUAZYcpBuRe3nS2OIUg==", "license": "MIT" }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@chainsafe/as-chacha20poly1305": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@chainsafe/as-chacha20poly1305/-/as-chacha20poly1305-0.1.0.tgz", @@ -882,6 +955,29 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1731,6 +1827,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@sindresorhus/fnv1a": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@sindresorhus/fnv1a/-/fnv1a-3.1.0.tgz", @@ -2304,6 +2407,151 @@ "node": ">=15" } }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@waku/core": { "version": "0.0.38", "resolved": "https://registry.npmjs.org/@waku/core/-/core-0.0.38.tgz", @@ -2565,6 +2813,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3206,6 +3467,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dns-over-http-resolver": { "version": "3.0.16", "resolved": "https://registry.npmjs.org/dns-over-http-resolver/-/dns-over-http-resolver-3.0.16.tgz", @@ -3629,6 +3900,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", @@ -3660,6 +3941,30 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -3961,6 +4266,13 @@ "node": ">= 0.6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4036,6 +4348,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -4169,6 +4494,13 @@ "resolved": "https://registry.npmjs.org/hi-base32/-/hi-base32-0.5.1.tgz", "integrity": "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -4185,6 +4517,16 @@ "node": ">= 0.8" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4254,6 +4596,18 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4387,6 +4741,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4419,6 +4786,60 @@ "ws": "*" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/it-all": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/it-all/-/it-all-3.0.9.tgz", @@ -4682,6 +5103,13 @@ "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.9.3.tgz", "integrity": "sha512-BcJPCQeLg6WjEx3FE591wVAevlli8lxsxm9/FzV4HXkV49TmBH38Yvrpce6fjbADGMKFrBMGTqrVz3qPIZ88Gg==" }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -4835,6 +5263,23 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4907,12 +5352,40 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/main-event": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/main-event/-/main-event-1.0.1.tgz", - "integrity": "sha512-NWtdGrAca/69fm6DIVd8T9rtfDII4Q8NQbIbsKQq2VzS9eqOGYs8uaNQjcuaCq/d9H/o625aOTJX2Qoxzqw0Pw==", + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/main-event": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/main-event/-/main-event-1.0.1.tgz", + "integrity": "sha512-NWtdGrAca/69fm6DIVd8T9rtfDII4Q8NQbIbsKQq2VzS9eqOGYs8uaNQjcuaCq/d9H/o625aOTJX2Qoxzqw0Pw==", "license": "Apache-2.0 OR MIT" }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -4946,6 +5419,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5021,6 +5501,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5193,6 +5686,25 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5274,6 +5786,35 @@ "integrity": "sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==", "license": "MIT" }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5316,12 +5857,38 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/one-webcrypto": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/one-webcrypto/-/one-webcrypto-1.0.3.tgz", "integrity": "sha512-fu9ywBVBPx0gS9K0etIROTiCkvI5S1TDjFsYFb3rC1ewFxeOqsbzq7aIMBHsYfrTHBcGXJaONXXjTl8B01cW1Q==", "license": "MIT" }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5512,6 +6079,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5602,6 +6179,35 @@ "pathe": "^2.0.1" } }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -5671,6 +6277,34 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/progress-events": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/progress-events/-/progress-events-1.0.1.tgz", @@ -5819,6 +6453,13 @@ "node": ">= 0.8" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -6167,6 +6808,13 @@ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", "license": "MIT" }, + "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", @@ -6235,6 +6883,16 @@ "node": ">= 8" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map/node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -6273,6 +6931,13 @@ "memory-pager": "^1.0.2" } }, + "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/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -6288,6 +6953,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -6392,6 +7064,19 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6405,6 +7090,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -6441,6 +7139,67 @@ "node": ">=8" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6464,6 +7223,13 @@ "node": ">=0.8" } }, + "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": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -6519,6 +7285,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6858,36 +7644,629 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/weald": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/weald/-/weald-1.0.6.tgz", - "integrity": "sha512-sX1PzkcMJZUJ848JbFzB6aKHHglTxqACEnq2KgI75b7vWYvfXFBNbOuDKqFKwCT44CrP6c5r+L4+5GmPnb5/SQ==", - "license": "Apache-2.0 OR MIT", - "dependencies": { - "ms": "^3.0.0-canary.1", - "supports-color": "^10.0.0" - } - }, - "node_modules/weald/node_modules/ms": { - "version": "3.0.0-canary.202508261828", - "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.202508261828.tgz", - "integrity": "sha512-NotsCoUCIUkojWCzQff4ttdCfIPoA1UGZsyQbi7KmqkNRfKCrvga8JJi2PknHymHOuor0cJSn/ylj52Cbt2IrQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/weald/node_modules/supports-color": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.0.tgz", - "integrity": "sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==", + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=18" + "node": "^18.0.0 || >=20.0.0" }, "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/weald": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/weald/-/weald-1.0.6.tgz", + "integrity": "sha512-sX1PzkcMJZUJ848JbFzB6aKHHglTxqACEnq2KgI75b7vWYvfXFBNbOuDKqFKwCT44CrP6c5r+L4+5GmPnb5/SQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "ms": "^3.0.0-canary.1", + "supports-color": "^10.0.0" + } + }, + "node_modules/weald/node_modules/ms": { + "version": "3.0.0-canary.202508261828", + "resolved": "https://registry.npmjs.org/ms/-/ms-3.0.0-canary.202508261828.tgz", + "integrity": "sha512-NotsCoUCIUkojWCzQff4ttdCfIPoA1UGZsyQbi7KmqkNRfKCrvga8JJi2PknHymHOuor0cJSn/ylj52Cbt2IrQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/weald/node_modules/supports-color": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.0.tgz", + "integrity": "sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } }, "node_modules/web-streams-polyfill": { "version": "3.3.3", @@ -6949,6 +8328,23 @@ "node": ">= 8" } }, + "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", @@ -7054,6 +8450,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index 1082faf..d407d14 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "clean": "rm -rf dist", "format": "prettier --write .", "lint": "eslint src/**/*.ts", + "test": "vitest", + "test:coverage": "vitest run --coverage", "postinstall": "npm run build", "heroku-postbuild": "npm run build" }, @@ -64,12 +66,14 @@ "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^8.6.0", "@typescript-eslint/parser": "^8.6.0", + "@vitest/coverage-v8": "^1.6.1", "eslint": "^9.11.0", "eslint-config-prettier": "^9.1.0", "prettier": "^3.3.3", "tsup": "^8.2.4", "tsx": "^4.7.1", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vitest": "^1.6.0" }, "overrides": { "uint8arrays": "^5.1.0" diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 5174d56..d9a0485 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -191,20 +191,6 @@ export class SessionManager { } } - async terminateOtherExistingSessions(documentId: string, ownerDid: string): Promise { - try { - const existingSessions = await SessionModel.find({ documentId, ownerDid, state: "active" }); - for (const session of existingSessions) { - await this.terminateSession(documentId, session.sessionDid); - console.log( - `[SessionManager] Terminated session: ${session.sessionDid} for document: ${documentId}` - ); - } - } catch (error) { - console.error("Error terminating existing sessions:", error); - } - } - async getActiveSessionsCount(): Promise { const inMemoryCount = this.inMemorySessions.size; if (inMemoryCount > 0) { diff --git a/src/services/socket-handlers.deps.ts b/src/services/socket-handlers.deps.ts new file mode 100644 index 0000000..5a09831 --- /dev/null +++ b/src/services/socket-handlers.deps.ts @@ -0,0 +1,9 @@ +import type { AuthService } from "./auth"; +import type { SessionManager } from "./session-manager"; +import type { MongoDBStore } from "./mongodb-store"; + +export interface SocketHandlerDeps { + authService: AuthService; + sessionManager: SessionManager; + mongodbStore: MongoDBStore; +} diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index 197429d..412a626 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -24,6 +24,13 @@ import { mongodbStore } from "./mongodb-store"; import { sessionManager } from "./session-manager"; import { Hex, isAddress } from "viem"; import type { BroadcastBridge } from "./broadcast-bridge"; +import type { SocketHandlerDeps } from "./socket-handlers.deps"; + +const defaultDeps: SocketHandlerDeps = { + authService, + sessionManager, + mongodbStore, +}; let bridge: BroadcastBridge | null = null; @@ -34,7 +41,7 @@ function validateHexAddress(address: string | undefined, fieldName: string): add return true; } -function getRoomName(documentId: string, sessionDid: string): string { +export function getRoomName(documentId: string, sessionDid: string): string { return `session::${documentId}__${sessionDid}`; } @@ -42,6 +49,7 @@ export function registerEventHandlers(io: AppServer, broadcastBridge?: Broadcast if (broadcastBridge) { bridge = broadcastBridge; } + io.on("connection", (socket: AppSocket) => { console.log(`New Socket.IO connection: ${socket.id}`); @@ -52,17 +60,27 @@ export function registerEventHandlers(io: AppServer, broadcastBridge?: Broadcast }); // Register event handlers - socket.on("/auth", (args, callback) => handleAuth(io, socket, args, callback)); - socket.on("/documents/update", (args, callback) => handleDocumentUpdate(io, socket, args, callback)); - socket.on("/documents/commit", (args, callback) => handleDocumentCommit(socket, args, callback)); - socket.on("/documents/commit/history", (args, callback) => handleCommitHistory(socket, args, callback)); - socket.on("/documents/update/history", (args, callback) => handleUpdateHistory(socket, args, callback)); + socket.on("/auth", (args, callback) => handleAuth(defaultDeps, io, socket, args, callback)); + socket.on("/documents/update", (args, callback) => + handleDocumentUpdate(defaultDeps, io, socket, args, callback) + ); + socket.on("/documents/commit", (args, callback) => + handleDocumentCommit(defaultDeps, socket, args, callback) + ); + socket.on("/documents/commit/history", (args, callback) => + handleCommitHistory(defaultDeps, socket, args, callback) + ); + socket.on("/documents/update/history", (args, callback) => + handleUpdateHistory(defaultDeps, socket, args, callback) + ); socket.on("/documents/peers/list", (args, callback) => handlePeersList(io, socket, args, callback)); socket.on("/documents/awareness", (args) => handleAwareness(io, socket, args)); - socket.on("/documents/terminate", (args, callback) => handleTerminateSession(io, socket, args, callback)); + socket.on("/documents/terminate", (args, callback) => + handleTerminateSession(defaultDeps, io, socket, args, callback) + ); // Disconnection handling - socket.on("disconnecting", () => handleDisconnecting(socket)); + socket.on("disconnecting", () => handleDisconnecting(defaultDeps, socket)); socket.on("disconnect", (reason) => { console.log(`Socket disconnected: ${socket.id}, reason: ${reason}`); }); @@ -72,13 +90,15 @@ export function registerEventHandlers(io: AppServer, broadcastBridge?: Broadcast }); } -async function handleAuth( +export async function handleAuth( + deps: SocketHandlerDeps, io: AppServer, socket: AppSocket, args: AuthArgs, callback: (response: AckResponse) => void ): Promise { try { + const { authService, sessionManager } = deps; const { documentId, collaborationToken, sessionDid } = args; if (!collaborationToken) { @@ -115,8 +135,8 @@ async function handleAuth( let roomInfo: string | undefined; if (!existingSession && args.ownerToken) { - // - Setup new session (owner flow) - - if (!args.ownerToken || !sessionDid) { + // - Set up a new session (owner flow) + if (!args.ownerToken || !args.sessionDid) { return callback({ status: false, statusCode: 400, @@ -200,7 +220,7 @@ async function handleAuth( sessionType = "new"; roomInfo = args.roomInfo; } else if (existingSession) { - // - Join existing session - + // Join an existing session const userDid = await authService.verifyCollaborationToken( collaborationToken, existingSession.sessionDid, @@ -305,13 +325,15 @@ async function handleAuth( } } -async function handleDocumentUpdate( +export async function handleDocumentUpdate( + deps: SocketHandlerDeps, io: AppServer, socket: AppSocket, args: DocumentUpdateArgs, callback: (response: AckResponse) => void ): Promise { try { + const { authService, sessionManager, mongodbStore } = deps; if (!requireAuth(socket)) { return callback({ status: false, @@ -413,12 +435,14 @@ async function handleDocumentUpdate( } } -async function handleDocumentCommit( +export async function handleDocumentCommit( + deps: SocketHandlerDeps, socket: AppSocket, args: DocumentCommitArgs, callback: (response: AckResponse) => void ): Promise { try { + const { authService, sessionManager, mongodbStore } = deps; if (!requireAuth(socket)) { return callback({ status: false, @@ -517,12 +541,14 @@ async function handleDocumentCommit( } } -async function handleCommitHistory( +export async function handleCommitHistory( + deps: SocketHandlerDeps, socket: AppSocket, args: CommitHistoryArgs, callback: (response: AckResponse<{ history: DocumentCommit[]; total: number }>) => void ): Promise { try { + const { mongodbStore } = deps; if (!requireAuth(socket)) { return callback({ status: false, @@ -560,12 +586,14 @@ async function handleCommitHistory( } } -async function handleUpdateHistory( +export async function handleUpdateHistory( + deps: SocketHandlerDeps, socket: AppSocket, args: UpdateHistoryArgs, callback: (response: AckResponse<{ history: DocumentUpdate[]; total: number }>) => void ): Promise { try { + const { mongodbStore } = deps; if (!requireAuth(socket)) { return callback({ status: false, @@ -603,7 +631,7 @@ async function handleUpdateHistory( } } -async function handlePeersList( +export async function handlePeersList( io: AppServer, socket: AppSocket, args: PeersListArgs, @@ -647,7 +675,7 @@ async function handlePeersList( } } -async function handleAwareness( +export async function handleAwareness( io: AppServer, socket: AppSocket, args: AwarenessArgs, @@ -674,13 +702,15 @@ async function handleAwareness( } } -async function handleTerminateSession( +export async function handleTerminateSession( + deps: SocketHandlerDeps, io: AppServer, socket: AppSocket, args: TerminateSessionArgs, callback: (response: AckResponse<{ message: string }>) => void ): Promise { try { + const { authService, sessionManager } = deps; const { documentId, sessionDid, ownerToken, ownerAddress, contractAddress } = args; console.log("TERMINATING SESSION", documentId); @@ -772,10 +802,12 @@ async function handleTerminateSession( } } -async function handleDisconnecting( +export async function handleDisconnecting( + deps: SocketHandlerDeps, socket: AppSocket ): Promise { try { + const { sessionManager } = deps; if (!socket.data.authenticated || !socket.data.documentId || !socket.data.sessionDid) { return; } diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts new file mode 100644 index 0000000..750d3d2 --- /dev/null +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -0,0 +1,655 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleAuth, getRoomName } from "../../services/socket-handlers"; +import { AppServer, AppSocket, AuthArgs, ErrorCode } from "../../types"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; + +function createFakeIO(options?: { + broadcastOperator?: { emit: ReturnType }; + fetchSockets?: ReturnType; +}): AppServer { + const roomBroadcastOperator = options?.broadcastOperator ?? { emit: vi.fn() }; + const fetchSockets = options?.fetchSockets ?? vi.fn().mockResolvedValue([]); + + return { + to: vi.fn(() => roomBroadcastOperator), + in: vi.fn(() => ({ fetchSockets })), + } as unknown as AppServer; +} + +function createFakeSocket( + broadcastOperator?: { emit: ReturnType }, + dataOverrides?: Partial<{ + authenticated: boolean; + documentId: string; + sessionDid: string; + role: "owner" | "editor"; + }> +): AppSocket { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + const defaultData = { + authenticated: false, + documentId: "", + sessionDid: "", + role: "editor" as const, + }; + const data = { ...defaultData, ...dataOverrides }; + + return { + id: "socket-1", + data, + to: vi.fn(() => toReturn), + join: vi.fn(), + } as unknown as AppSocket; +} + +describe("handleAuth", () => { + const fakeAuthService = { + verifyOwnerToken: vi.fn(), + verifyCollaborationToken: vi.fn(), + getServerDid: vi.fn(), + }; + const fakeSessionManager = { + getSession: vi.fn(), + getOtherActiveSessions: vi.fn(), + terminateSession: vi.fn(), + createSession: vi.fn(), + updateRoomInfo: vi.fn(), + addClientToSession: vi.fn(), + }; + const fakeMongoDBStore = {} as any; + + const deps: SocketHandlerDeps = { + authService: fakeAuthService as any, + sessionManager: fakeSessionManager as any, + mongodbStore: fakeMongoDBStore, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when collaborationToken is missing", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "" as any, + }; + const callback = vi.fn(); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Collaboration token is required", + errorCode: ErrorCode.AUTH_TOKEN_MISSING, + }); + }); + + it("returns 400 when documentId is missing", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "" as any, + sessionDid: "session-1", + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Document ID is required", + errorCode: ErrorCode.DOCUMENT_ID_MISSING, + }); + }); + + it("returns 400 when sessionDid is missing", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "" as any, + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Session DID is required", + errorCode: ErrorCode.SESSION_DID_MISSING, + }); + }); + + it("creates a new owner session when no existing session and ownerToken is provided", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + roomInfo: "room-info", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeSessionManager.getOtherActiveSessions.mockResolvedValue([]); + fakeSessionManager.createSession.mockResolvedValue(undefined); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.getSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid + ); + expect(fakeAuthService.verifyOwnerToken).toHaveBeenCalledWith( + fakeArgs.ownerToken, + fakeArgs.contractAddress, + fakeArgs.ownerAddress + ); + expect(fakeSessionManager.getOtherActiveSessions).toHaveBeenCalledWith( + fakeArgs.documentId, + "owner-did", + fakeArgs.sessionDid, + ); + expect(fakeSessionManager.createSession).toHaveBeenCalledWith({ + documentId: fakeArgs.documentId, + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: fakeArgs.roomInfo, + }); + + expect(fakeSocket.data.authenticated).toBe(true); + expect(fakeSocket.data.documentId).toBe(fakeArgs.documentId); + expect(fakeSocket.data.sessionDid).toBe(fakeArgs.sessionDid); + expect(fakeSocket.data.role).toBe("owner"); + + const roomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); + expect(fakeSocket.join).toHaveBeenCalledWith(roomName); + expect(fakeSessionManager.addClientToSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid, + fakeSocket.id + ); + expect(fakeSocket.to).toHaveBeenCalledWith(roomName); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/room/membership_change", { + action: "user_joined", + user: { role: "owner" }, + roomId: fakeArgs.documentId, + }); + + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + message: "Authentication successful", + role: "owner", + sessionType: "new", + roomInfo: fakeArgs.roomInfo, + }, + }); + }); + + it("creates a new owner session and terminates other active sessions when they exist", async () => { + const fakeRoomBroadcastOperator = { emit: vi.fn() }; + const fetchSockets = vi.fn(); + const fakeIO = createFakeIO({ broadcastOperator: fakeRoomBroadcastOperator, fetchSockets }); + + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + roomInfo: "room-info", + }; + const callback = vi.fn(); + + const otherSessions = [ + { documentId: fakeArgs.documentId, sessionDid: "old-session-1" }, + { documentId: fakeArgs.documentId, sessionDid: "old-session-2" }, + ]; + + const oldRoomName1 = getRoomName(fakeArgs.documentId, otherSessions[0].sessionDid); + const oldRoomName2 = getRoomName(fakeArgs.documentId, otherSessions[1].sessionDid); + const oldSocket1 = createFakeSocket(undefined, { authenticated: true }) as any; + const oldSocket2 = createFakeSocket(undefined, { authenticated: true }) as any; + oldSocket1.leave = vi.fn(); + oldSocket2.leave = vi.fn(); + + fetchSockets + .mockResolvedValueOnce([oldSocket1]) + .mockResolvedValueOnce([oldSocket2]); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeSessionManager.getOtherActiveSessions.mockResolvedValue(otherSessions); + fakeSessionManager.terminateSession.mockResolvedValue(undefined); + fakeSessionManager.createSession.mockResolvedValue(undefined); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + // Pre-loop checks (sequence before termination loop) + expect(fakeSessionManager.getSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid + ); + expect(fakeAuthService.verifyOwnerToken).toHaveBeenCalledWith( + fakeArgs.ownerToken, + fakeArgs.contractAddress, + fakeArgs.ownerAddress + ); + expect(fakeSessionManager.getOtherActiveSessions).toHaveBeenCalledWith( + fakeArgs.documentId, + "owner-did", + fakeArgs.sessionDid, + ); + + // First other session (old-session-1) + expect(fakeIO.to).toHaveBeenCalledWith(oldRoomName1); + expect(fakeRoomBroadcastOperator.emit).toHaveBeenCalledWith("/server/error", { + errorCode: ErrorCode.SESSION_TERMINATED, + message: "Session terminated by owner creating a new session", + roomId: otherSessions[0].documentId, + }); + expect(fakeIO.to).toHaveBeenCalledWith(oldRoomName1); + expect(fakeRoomBroadcastOperator.emit).toHaveBeenCalledWith("/session/terminated", { + roomId: otherSessions[0].documentId, + }); + expect(fakeIO.in).toHaveBeenCalledWith(oldRoomName1); + expect(fetchSockets).toHaveBeenCalledWith(); + expect(oldSocket1.data.authenticated).toBe(false); + expect(oldSocket1.leave).toHaveBeenCalledWith(oldRoomName1); + expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( + otherSessions[0].documentId, + otherSessions[0].sessionDid + ); + + // Second other session (old-session-2) + expect(fakeIO.to).toHaveBeenCalledWith(oldRoomName2); + expect(fakeRoomBroadcastOperator.emit).toHaveBeenCalledWith("/server/error", { + errorCode: ErrorCode.SESSION_TERMINATED, + message: "Session terminated by owner creating a new session", + roomId: otherSessions[1].documentId, + }); + expect(fakeIO.to).toHaveBeenCalledWith(oldRoomName2); + expect(fakeRoomBroadcastOperator.emit).toHaveBeenCalledWith("/session/terminated", { + roomId: otherSessions[1].documentId, + }); + expect(fakeIO.in).toHaveBeenCalledWith(oldRoomName2); + expect(fetchSockets).toHaveBeenCalledWith(); + expect(oldSocket2.data.authenticated).toBe(false); + expect(oldSocket2.leave).toHaveBeenCalledWith(oldRoomName2); + expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( + otherSessions[1].documentId, + otherSessions[1].sessionDid + ); + + // Aggregate call-count assertions after verifying per-iteration sequence + // Outside if-else block + expect(fetchSockets).toHaveBeenCalledTimes(otherSessions.length); + expect(fakeSessionManager.terminateSession).toHaveBeenCalledTimes(otherSessions.length); + + expect(fakeSocket.data.authenticated).toBe(true); + expect(fakeSocket.data.documentId).toBe(fakeArgs.documentId); + expect(fakeSocket.data.sessionDid).toBe(fakeArgs.sessionDid); + expect(fakeSocket.data.role).toBe("owner"); + + const roomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); + expect(fakeSocket.join).toHaveBeenCalledWith(roomName); + expect(fakeSessionManager.addClientToSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid, + fakeSocket.id + ); + expect(fakeSocket.to).toHaveBeenCalledWith(roomName); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/room/membership_change", { + action: "user_joined", + user: { role: "owner" }, + roomId: fakeArgs.documentId, + }); + + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + message: "Authentication successful", + role: "owner", + sessionType: "new", + roomInfo: fakeArgs.roomInfo, + }, + }); + }); + + it("joins an existing session as editor when collaboration token is valid", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeAuthService.verifyCollaborationToken).toHaveBeenCalledWith( + fakeArgs.collaborationToken, + existingSession.sessionDid, + fakeArgs.documentId + ); + + const roomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); + expect(fakeSocket.join).toHaveBeenCalledWith(roomName); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/room/membership_change", { + action: "user_joined", + user: { role: "editor" }, + roomId: fakeArgs.documentId, + }); + + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + message: "Authentication successful", + role: "editor", + sessionType: "existing", + roomInfo: existingSession.roomInfo, + }, + }); + }); + + it("returns 404 when existing session is not found and no ownerToken is provided", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 404, + error: "Session not found", + errorCode: ErrorCode.SESSION_NOT_FOUND, + }); + }); + + it("returns 400 when in owner flow but owner token or session DID is missing", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + + let sessionDidReadCount = 0; + const argsWithGetterSessionDid = { + documentId: "doc-1", + get sessionDid() { + sessionDidReadCount++; + return sessionDidReadCount === 1 ? "session-1" : (undefined as any); + }, + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + } as AuthArgs; + + await handleAuth(deps, fakeIO, fakeSocket, argsWithGetterSessionDid, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Document ID, owner token, and session DID are required", + errorCode: ErrorCode.AUTH_TOKEN_MISSING, + }); + }); + + it("returns 400 when in owner flow with invalid contract or owner address format", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "not-a-valid-address", + contractAddress: "0x0000000000000000000000000000000000000002", + roomInfo: "room-info", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Invalid contract address or owner address format", + errorCode: ErrorCode.INVALID_ADDRESS, + }); + }); + + it("returns 400 when joining existing session with owner token but invalid contract or owner address", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "invalid-hex", + contractAddress: "0x0000000000000000000000000000000000000002", + roomInfo: "room-info", + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Invalid contract address or owner address format", + errorCode: ErrorCode.INVALID_ADDRESS, + }); + }); + + it("returns 401 when owner token verification fails in owner flow", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue(null); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.getSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid + ); + expect(fakeAuthService.verifyOwnerToken).toHaveBeenCalledWith( + fakeArgs.ownerToken, + fakeArgs.contractAddress, + fakeArgs.ownerAddress + ); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Authentication failed", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + }); + + it("returns 401 when collaboration token verification fails for existing session", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue(null); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeAuthService.verifyCollaborationToken).toHaveBeenCalledWith( + fakeArgs.collaborationToken, + existingSession.sessionDid, + fakeArgs.documentId + ); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Authentication failed", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + }); + + it("joins existing session as owner and updates room info when owner token matches", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + roomInfo: "new-room-info", + }; + const callback = vi.fn(); + + const existingSession = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "owner-did", + roomInfo: "existing-room-info", + }; + + fakeSessionManager.getSession.mockResolvedValue(existingSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeSessionManager.updateRoomInfo.mockResolvedValue(undefined); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + const roomName = getRoomName(fakeArgs.documentId, fakeArgs.sessionDid); + + expect(fakeAuthService.verifyOwnerToken).toHaveBeenCalledWith( + fakeArgs.ownerToken, + fakeArgs.contractAddress, + fakeArgs.ownerAddress + ); + expect(fakeSessionManager.updateRoomInfo).toHaveBeenCalledWith( + fakeArgs.documentId, + existingSession.sessionDid, + existingSession.ownerDid, + fakeArgs.roomInfo + ); + + expect(fakeSocket.data.role).toBe("owner"); + expect(fakeSocket.join).toHaveBeenCalledWith(roomName); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/room/membership_change", { + action: "user_joined", + user: { role: "owner" }, + roomId: fakeArgs.documentId, + }); + + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + message: "Authentication successful", + role: "owner", + sessionType: "existing", + roomInfo: existingSession.roomInfo, + }, + }); + }); + + it("returns 500 when an unexpected error occurs in auth handler", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockRejectedValue(new Error("db error")); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: ErrorCode.INTERNAL_ERROR, + }); + }); +}); + diff --git a/src/tests/services/socket-handlers.handleAwareness.test.ts b/src/tests/services/socket-handlers.handleAwareness.test.ts new file mode 100644 index 0000000..d30197f --- /dev/null +++ b/src/tests/services/socket-handlers.handleAwareness.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, it, describe, vi, expect } from "vitest"; +import { handleAwareness } from "../../services/socket-handlers"; +import type { AppServer, AppSocket } from "../../types"; +import type { SocketData } from "../../types"; + +const defaultSocketData: SocketData = { + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "owner", + authenticated: true, +}; + +function createFakeIO(): AppServer { + return {} as unknown as AppServer; +} + +function createFakeSocket( + fakeBroadcastOperator?: { emit: ReturnType }, + dataOverrides?: Partial +): AppSocket { + const op = fakeBroadcastOperator ?? { emit: vi.fn() }; + const data: SocketData = { ...defaultSocketData, ...dataOverrides }; + + return { + id: "socket-1", + data, + to: vi.fn(() => op), + } as unknown as AppSocket; +} + +describe('handleAwareness', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns if socket is not authenticated", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(undefined, { authenticated: false }); + const fakeArgs = { + documentId: "", + data: {}, + collaborationToken: "", + }; + + await handleAwareness(fakeIO, fakeSocket, fakeArgs); + expect(fakeSocket.to).not.toHaveBeenCalled(); + }); + + it("broadcasts awareness update to all participants in room", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs = { + documentId: "test-document-id", + data: { + "position": "AnOj8HhKtKwIwoMhASL3k9y7OKz1t8OLOxBhGgLobtL3__n__ZergaGXSI4+831Mn__n__mjnKm/6GcoUByss9zPvU6hMYQ4hcesVBcSluOAUctFSFNshQak+GHWAzptk4j4NmIIPtihoEmm0XBS3Sa7whQ+tIThoX9J4UGlb5MYk4oAuWgy0zfwU4vjfgqo+NyzcF/mlMDYOvdfnlLKWE/H7jI3V61Ddll6I+3d6oIRfSS2jruzvZn2slDC1Esg7S+a6Uw0LGUxOyY2dXEaaocB9qmuJG8OGw8D4u23mA+IiBfaqKggmt9OOkGiO3xVLr70XNqYfUpJbs8u5kPMuxWX5trT7L+asNitrsBplUsA0Kf4KaJBIQLmVSIWHtwyAaWNSxAPQPP7zW0Gm4VnuY4eTCAjU/iYlx3A==__n__5pXP2B5Nmt+xlOuzGOW9wA==" + }, + collaborationToken: "test-collaborator-token", + }; + + await handleAwareness(fakeIO, fakeSocket, fakeArgs); + + const roomName = `session::${fakeArgs.documentId}__${fakeSocket.data.sessionDid}`; + expect(fakeSocket.to).toHaveBeenCalledWith(roomName); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/document/awareness_update", { + data: fakeArgs.data, + roomId: fakeArgs.documentId, + }); + }); + + it("does not throw when an error occurs in awareness handler", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(undefined, { authenticated: true }); + Object.defineProperty(fakeSocket, "to", { + get() { + throw new Error("socket.to failed"); + }, + }); + const fakeArgs = { + documentId: "test-document-id", + data: {}, + collaborationToken: "", + }; + + await expect(handleAwareness(fakeIO, fakeSocket as any, fakeArgs)).resolves.not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/tests/services/socket-handlers.handleCommitHistory.test.ts b/src/tests/services/socket-handlers.handleCommitHistory.test.ts new file mode 100644 index 0000000..3df70c4 --- /dev/null +++ b/src/tests/services/socket-handlers.handleCommitHistory.test.ts @@ -0,0 +1,198 @@ +import { describe, vi, it, expect, beforeEach } from "vitest"; +import { handleCommitHistory } from "../../services/socket-handlers"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import type { AppSocket, DocumentCommit, CommitHistoryArgs } from "../../types"; + +function createFakeSocket( + broadcastOperator?: { emit: ReturnType }, + dataOverrides?: Partial<{ + authenticated: boolean; + documentId: string; + sessionDid: string; + role: "owner" | "editor"; + }> +): AppSocket { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + const defaultData = { + authenticated: true, + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "owner" as const, + }; + const data = { ...defaultData, ...dataOverrides }; + + return { + id: "socket-1", + data, + to: vi.fn(() => toReturn), + } as unknown as AppSocket; +} + +describe("commitHistory", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const fakeMongodbStore = { + getCommitsByDocument: vi.fn(), + countCommitsByDocument: vi.fn(), + }; + + const deps: SocketHandlerDeps = { + authService: {} as any, + sessionManager: {} as any, + mongodbStore: fakeMongodbStore as any, + }; + + it("returns early when not authenticated", async () => { + const fakeSocket: AppSocket = createFakeSocket(undefined, { authenticated: false }); + const fakeArgs: CommitHistoryArgs = { + documentId: "test-document-id", + }; + const fakeCallback = vi.fn(); + + await handleCommitHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns early when documentId is empty in socket data", async () => { + const fakeSocket: AppSocket = createFakeSocket(undefined, { documentId: "" }); + const fakeArgs: CommitHistoryArgs = {}; + const fakeCallback = vi.fn(); + + await handleCommitHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns early when sessionDid is empty in socket data", async () => { + const fakeSocket: AppSocket = createFakeSocket(undefined, { sessionDid: "" }); + const fakeArgs: CommitHistoryArgs = { + documentId: "test-document-id", + }; + const fakeCallback = vi.fn(); + + await handleCommitHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns commit history successfully, with fallback argument values", async () => { + const fakeSocket: AppSocket = createFakeSocket(); + const fakeArgs: CommitHistoryArgs = {}; + const fakeCallback = vi.fn(); + const fakeResponse: DocumentCommit[] = []; + const documentId = fakeArgs.documentId || fakeSocket.data.documentId; + + fakeMongodbStore.getCommitsByDocument.mockResolvedValue(fakeResponse); + fakeMongodbStore.countCommitsByDocument.mockResolvedValue(fakeResponse.length); + + await handleCommitHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeMongodbStore.getCommitsByDocument).toHaveBeenCalledWith( + { documentId, sessionDid: fakeSocket.data.sessionDid }, + { offset: 0, limit: 10, sort: "desc" } + ); + expect(fakeMongodbStore.countCommitsByDocument).toHaveBeenCalledWith({ + documentId, + sessionDid: fakeSocket.data.sessionDid, + }); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + history: fakeResponse, + total: fakeResponse.length, + }, + }); + }); + + it("returns update history successfully with proper argument values set", async () => { + const fakeSocket: AppSocket = createFakeSocket(); + const fakeArgs: CommitHistoryArgs = { + documentId: "test-document-id", + limit: 15, + offset: 0, + sort: "desc", + }; + const fakeCallback = vi.fn(); + const fakeResponse: DocumentCommit[] = []; + const documentId = fakeArgs.documentId || fakeSocket.data.documentId; + + fakeMongodbStore.getCommitsByDocument.mockResolvedValue(fakeResponse); + fakeMongodbStore.countCommitsByDocument.mockResolvedValue(fakeResponse.length); + + await handleCommitHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeMongodbStore.getCommitsByDocument).toHaveBeenCalledWith( + { documentId, sessionDid: fakeSocket.data.sessionDid }, + { offset: fakeArgs.offset, limit: fakeArgs.limit, sort: fakeArgs.sort } + ); + expect(fakeMongodbStore.countCommitsByDocument).toHaveBeenCalledWith({ + documentId, + sessionDid: fakeSocket.data.sessionDid, + }); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + history: fakeResponse, + total: fakeResponse.length, + }, + }); + }); + + it("returns 500 due to db operation error", async () => { + const fakeSocket: AppSocket = createFakeSocket(); + const fakeArgs: CommitHistoryArgs = { + documentId: "test-document-id", + limit: 15, + offset: 0, + sort: "desc", + }; + const fakeCallback = vi.fn(); + const documentId = fakeArgs.documentId || fakeSocket.data.documentId; + + fakeMongodbStore.getCommitsByDocument.mockRejectedValue(new Error("db error")); + fakeMongodbStore.countCommitsByDocument.mockResolvedValue(0); + + await handleCommitHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeMongodbStore.getCommitsByDocument).toHaveBeenCalledWith( + { + documentId, + sessionDid: fakeSocket.data.sessionDid, + }, + { + offset: fakeArgs.offset, + limit: fakeArgs.limit, + sort: fakeArgs.sort, + } + ); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: "INTERNAL_ERROR", + }); + }); +}); \ No newline at end of file diff --git a/src/tests/services/socket-handlers.handleDisconnecting.test.ts b/src/tests/services/socket-handlers.handleDisconnecting.test.ts new file mode 100644 index 0000000..4310886 --- /dev/null +++ b/src/tests/services/socket-handlers.handleDisconnecting.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleDisconnecting } from "../../services/socket-handlers"; +import type { AppSocket } from "../../types"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; + +/** + * Fake socket for handler tests. + * If you pass a broadcastOperator, socket.to(room) will return it so you can assert + * on broadcastOperator.emit(event, payload) in the test. + * dataOverrides can be used to set authenticated, documentId, sessionDid, or role for early-return tests. + */ +function createFakeSocket( + broadcastOperator?: { emit: ReturnType }, + dataOverrides?: Partial<{ + authenticated: boolean; + documentId: string; + sessionDid: string; + role: "owner" | "editor"; + }> +): AppSocket { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + const defaultData = { + authenticated: true, + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "owner" as const, + }; + const data = { ...defaultData, ...dataOverrides }; + const socket = { + id: "socket-1", + data, + to: vi.fn(() => toReturn), + } as unknown as AppSocket; + return socket; +} + +describe("handleDisconnecting", () => { + const fakeAuthService = { + verifyOwnerToken: vi.fn<[], Promise>(), + }; + const fakeSessionManager = { + removeClientFromSession: vi.fn(), + }; + const fakeMongoDBStore = {} as any; + const deps: SocketHandlerDeps = { + authService: fakeAuthService as any, + sessionManager: fakeSessionManager as any, + mongodbStore: fakeMongoDBStore, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns early when socket is not authenticated", async () => { + const fakeSocket = createFakeSocket(undefined, { authenticated: false }); + + await handleDisconnecting(deps, fakeSocket); + + expect(fakeSocket.to).not.toHaveBeenCalled(); + expect(fakeSessionManager.removeClientFromSession).not.toHaveBeenCalled(); + }); + + it("returns early when documentId is missing", async () => { + const fakeSocket = createFakeSocket(undefined, { documentId: "" }); + + await handleDisconnecting(deps, fakeSocket); + + expect(fakeSocket.to).not.toHaveBeenCalled(); + expect(fakeSessionManager.removeClientFromSession).not.toHaveBeenCalled(); + }); + + it("returns early when sessionDid is missing", async () => { + const fakeSocket = createFakeSocket(undefined, { sessionDid: "" }); + + await handleDisconnecting(deps, fakeSocket); + + expect(fakeSocket.to).not.toHaveBeenCalled(); + expect(fakeSessionManager.removeClientFromSession).not.toHaveBeenCalled(); + }); + + it("broadcasts membership_change and calls removeClientFromSession when socket has full data", async () => { + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + + fakeSessionManager.removeClientFromSession.mockResolvedValue(undefined); + + await handleDisconnecting(deps, fakeSocket); + + const roomName = `session::${fakeSocket.data.documentId}__${fakeSocket.data.sessionDid}`; + + expect(fakeSocket.to).toHaveBeenCalledOnce(); + expect(fakeSocket.to).toHaveBeenCalledWith(roomName); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/room/membership_change", { + action: "user_left", + user: { role: fakeSocket.data.role }, + roomId: fakeSocket.data.documentId, + }); + + expect(fakeSessionManager.removeClientFromSession).toHaveBeenCalledOnce(); + expect(fakeSessionManager.removeClientFromSession).toHaveBeenCalledWith( + fakeSocket.data.documentId, + fakeSocket.data.sessionDid, + fakeSocket.id + ); + }); + + it("does not throw when an error occurs during disconnection cleanup", async () => { + const fakeSocket = createFakeSocket(undefined, { + authenticated: true, + documentId: "doc-1", + sessionDid: "session-1", + role: "owner", + }); + fakeSessionManager.removeClientFromSession.mockRejectedValue(new Error("db error")); + + await expect(handleDisconnecting(deps, fakeSocket)).resolves.not.toThrow(); + }); +}); diff --git a/src/tests/services/socket-handlers.handleDocumentCommit.test.ts b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts new file mode 100644 index 0000000..35a6118 --- /dev/null +++ b/src/tests/services/socket-handlers.handleDocumentCommit.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleDocumentCommit } from "../../services/socket-handlers"; +import type { AppSocket, DocumentCommitArgs } from "../../types"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import { ErrorCode } from "../../types"; + +function createFakeSocket( + dataOverrides?: Partial<{ + authenticated: boolean; + documentId: string; + sessionDid: string; + role: "owner" | "editor"; + }> +): AppSocket { + const defaultData = { + authenticated: true, + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "owner" as const, + }; + const data = { ...defaultData, ...dataOverrides }; + + return { + id: "socket-1", + data, + } as unknown as AppSocket; +} + +describe("handleDocumentCommit", () => { + const fakeAuthService = { + verifyOwnerToken: vi.fn(), + }; + const fakeSessionManager = { + getRuntimeSession: vi.fn(), + }; + const fakeMongoDBStore = { + createCommit: vi.fn(), + }; + + const deps: SocketHandlerDeps = { + authService: fakeAuthService as any, + sessionManager: fakeSessionManager as any, + mongodbStore: fakeMongoDBStore as any, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when socket is not authenticated", async () => { + const fakeSocket = createFakeSocket({ authenticated: false }); + const fakeArgs: DocumentCommitArgs = { + documentId: "doc-1", + updates: ["u1"], + cid: "cid-1", + ownerToken: "owner-token", + ownerAddress: "0xowner", + contractAddress: "0xcontract", + }; + const callback = vi.fn(); + + await handleDocumentCommit(deps, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns 403 when socket role is not owner", async () => { + const fakeSocket = createFakeSocket({ role: "editor" }); + const fakeArgs: DocumentCommitArgs = { + documentId: "doc-1", + updates: ["u1"], + cid: "cid-1", + ownerToken: "owner-token", + ownerAddress: "0xowner", + contractAddress: "0xcontract", + }; + const callback = vi.fn(); + + await handleDocumentCommit(deps, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 403, + error: "Only owners can create commits", + errorCode: "COMMIT_UNAUTHORIZED", + }); + }); + + it("returns 404 when runtime session is not found", async () => { + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentCommitArgs = { + documentId: "doc-1", + updates: ["u1"], + cid: "cid-1", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + fakeSessionManager.getRuntimeSession.mockResolvedValue(undefined); + + await handleDocumentCommit(deps, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.getRuntimeSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSocket.data.sessionDid + ); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 404, + error: "Session not found", + errorCode: "SESSION_NOT_FOUND", + }); + }); + + it("returns 400 when updates or cid are missing or invalid", async () => { + const fakeSocket = createFakeSocket(); + const callback = vi.fn(); + + const badArgsList: DocumentCommitArgs[] = [ + // @ts-expect-error testing missing required fields (updates, cid, owner fields) + { + documentId: "doc-1", + }, + { + documentId: "doc-1", + // deliberately non-array to test validation + updates: null as any, + cid: "cid-1", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }, + { + documentId: "doc-1", + updates: ["u1"], + cid: "" as any, + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }, + ]; + + for (const badArgs of badArgsList) { + const runtimeSession = { sessionDid: fakeSocket.data.sessionDid }; + fakeSessionManager.getRuntimeSession.mockResolvedValue(runtimeSession); + fakeAuthService.verifyOwnerToken.mockResolvedValue(true); + + await handleDocumentCommit(deps, fakeSocket, badArgs as any, callback); + + expect(callback).toHaveBeenLastCalledWith({ + status: false, + statusCode: 400, + error: "Updates array and CID are required", + errorCode: "COMMIT_MISSING_DATA", + }); + } + }); + + it("returns 400 when contract or owner address format is invalid", async () => { + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentCommitArgs = { + documentId: "doc-1", + updates: ["u1"], + cid: "cid-1", + ownerToken: "owner-token", + ownerAddress: "not-a-valid-address", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + const runtimeSession = { sessionDid: fakeSocket.data.sessionDid }; + fakeSessionManager.getRuntimeSession.mockResolvedValue(runtimeSession); + + await handleDocumentCommit(deps, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Invalid contract address or owner address format", + errorCode: ErrorCode.INVALID_ADDRESS, + }); + expect(fakeAuthService.verifyOwnerToken).not.toHaveBeenCalled(); + }); + + it("returns 401 when owner token verification fails", async () => { + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentCommitArgs = { + documentId: "doc-1", + updates: ["u1"], + cid: "cid-1", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + const runtimeSession = { sessionDid: fakeSocket.data.sessionDid }; + fakeSessionManager.getRuntimeSession.mockResolvedValue(runtimeSession); + fakeAuthService.verifyOwnerToken.mockResolvedValue(false); + + await handleDocumentCommit(deps, fakeSocket, fakeArgs, callback); + expect(fakeSessionManager.getRuntimeSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSocket.data.sessionDid + ); + expect(fakeAuthService.verifyOwnerToken).toHaveBeenCalledWith( + fakeArgs.ownerToken, + fakeArgs.contractAddress, + fakeArgs.ownerAddress + ); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Authentication failed", + errorCode: "AUTH_TOKEN_INVALID", + }); + }); + + it("creates commit when all checks pass", async () => { + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentCommitArgs = { + documentId: "doc-1", + updates: ["u1", "u2"], + cid: "cid-1", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + const runtimeSession = { sessionDid: fakeSocket.data.sessionDid }; + fakeSessionManager.getRuntimeSession.mockResolvedValue(runtimeSession); + fakeAuthService.verifyOwnerToken.mockResolvedValue(true); + + const fakeCommit = { + id: "commit-id", + documentId: fakeArgs.documentId, + cid: fakeArgs.cid, + updates: fakeArgs.updates, + createdAt: 123456, + sessionDid: runtimeSession.sessionDid, + }; + fakeMongoDBStore.createCommit.mockResolvedValue(fakeCommit); + + await handleDocumentCommit(deps, fakeSocket, fakeArgs, callback); + expect(fakeSessionManager.getRuntimeSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSocket.data.sessionDid + ); + expect(fakeMongoDBStore.createCommit).toHaveBeenCalledWith({ + id: expect.any(String), + documentId: fakeArgs.documentId, + cid: fakeArgs.cid, + updates: fakeArgs.updates, + createdAt: expect.any(Number), + sessionDid: runtimeSession.sessionDid, + }); + + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + cid: fakeCommit.cid, + createdAt: fakeCommit.createdAt, + documentId: fakeCommit.documentId, + updates: fakeCommit.updates, + }, + }); + }); + + it("returns 500 when an unexpected error occurs in document commit handler", async () => { + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentCommitArgs = { + documentId: "doc-1", + updates: ["u1"], + cid: "cid-1", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + fakeSessionManager.getRuntimeSession.mockRejectedValue(new Error("db error")); + + await handleDocumentCommit(deps, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: ErrorCode.INTERNAL_ERROR, + }); + }); +}); + diff --git a/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts new file mode 100644 index 0000000..751b75e --- /dev/null +++ b/src/tests/services/socket-handlers.handleDocumentUpdate.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleDocumentUpdate, getRoomName } from "../../services/socket-handlers"; +import type { AppServer, AppSocket, DocumentUpdateArgs } from "../../types"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import { ErrorCode } from "../../types"; + +function createFakeIO(): AppServer { + return {} as unknown as AppServer; +} + +function createFakeSocket( + broadcastOperator?: { emit: ReturnType }, + dataOverrides?: Partial<{ + authenticated: boolean; + documentId: string; + sessionDid: string; + role: "owner" | "editor"; + }> +): AppSocket { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + const defaultData = { + authenticated: true, + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "owner" as const, + }; + const data = { ...defaultData, ...dataOverrides }; + + return { + id: "socket-1", + data, + to: vi.fn(() => toReturn), + } as unknown as AppSocket; +} + +describe("handleDocumentUpdate", () => { + const fakeAuthService = { + verifyCollaborationToken: vi.fn(), + }; + const fakeSessionManager = { + getRuntimeSession: vi.fn(), + }; + const fakeMongoDBStore = { + createUpdate: vi.fn(), + }; + + const deps: SocketHandlerDeps = { + authService: fakeAuthService as any, + sessionManager: fakeSessionManager as any, + mongodbStore: fakeMongoDBStore as any, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 401 when socket is not authenticated", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(undefined, { authenticated: false }); + const fakeArgs: DocumentUpdateArgs = { + documentId: "doc-1", + data: "update-data", + collaborationToken: "token", + }; + const callback = vi.fn(); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns 400 when data is missing", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentUpdateArgs = { + documentId: "doc-1", + data: "" as any, + collaborationToken: "token", + }; + const callback = vi.fn(); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Update data is required", + errorCode: "UPDATE_DATA_MISSING", + }); + }); + + it("returns 404 when runtime session is not found", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentUpdateArgs = { + documentId: "doc-1", + data: "update-data", + collaborationToken: "token", + }; + const callback = vi.fn(); + + fakeSessionManager.getRuntimeSession.mockResolvedValue(undefined); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.getRuntimeSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSocket.data.sessionDid + ); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 404, + error: "Session not found", + errorCode: "SESSION_NOT_FOUND", + }); + }); + + it("returns 401 when collaboration token verification fails", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentUpdateArgs = { + documentId: "doc-1", + data: "update-data", + collaborationToken: "token", + }; + const callback = vi.fn(); + + const runtimeSession = { sessionDid: fakeSocket.data.sessionDid }; + fakeSessionManager.getRuntimeSession.mockResolvedValue(runtimeSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue(false); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + expect(fakeSessionManager.getRuntimeSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSocket.data.sessionDid, + ); + expect(fakeAuthService.verifyCollaborationToken).toHaveBeenCalledWith( + fakeArgs.collaborationToken, + runtimeSession.sessionDid, + fakeArgs.documentId + ); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Authentication failed", + errorCode: "AUTH_TOKEN_INVALID", + }); + }); + + it("creates update and broadcasts when all checks pass", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: DocumentUpdateArgs = { + documentId: "doc-1", + data: "update-data", + collaborationToken: "token", + }; + const callback = vi.fn(); + + const runtimeSession = { sessionDid: fakeSocket.data.sessionDid }; + fakeSessionManager.getRuntimeSession.mockResolvedValue(runtimeSession); + fakeAuthService.verifyCollaborationToken.mockResolvedValue(true); + + const fakeUpdate = { + id: "some-id", + documentId: fakeArgs.documentId, + data: fakeArgs.data, + updateType: "yjs_update", + committed: false, + commitCid: null, + createdAt: 1000, + sessionDid: runtimeSession.sessionDid, + }; + fakeMongoDBStore.createUpdate.mockResolvedValue(fakeUpdate); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.getRuntimeSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSocket.data.sessionDid + ); + expect(fakeAuthService.verifyCollaborationToken).toHaveBeenCalledWith( + fakeArgs.collaborationToken, + runtimeSession.sessionDid, + fakeArgs.documentId + ); + expect(fakeMongoDBStore.createUpdate).toHaveBeenCalledWith({ + id: expect.any(String), + documentId: fakeArgs.documentId, + data: fakeArgs.data, + updateType: "yjs_update", + committed: false, + commitCid: null, + createdAt: expect.any(Number), + sessionDid: runtimeSession.sessionDid, + }); + + const roomName = getRoomName(fakeArgs.documentId!, fakeSocket.data.sessionDid); + expect(fakeSocket.to).toHaveBeenCalledWith(roomName); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/document/content_update", { + id: expect.any(String), + data: fakeArgs.data, + createdAt: expect.any(Number), + roomId: fakeArgs.documentId, + }); + + expect(callback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + id: expect.any(String), + documentId: fakeUpdate.documentId, + data: fakeUpdate.data, + updateType: fakeUpdate.updateType, + commitCid: fakeUpdate.commitCid, + createdAt: expect.any(Number), + }, + }); + }); + + it("returns 500 when an unexpected error occurs in document update handler", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs: DocumentUpdateArgs = { + documentId: "doc-1", + data: "update-data", + collaborationToken: "token", + }; + const callback = vi.fn(); + + fakeSessionManager.getRuntimeSession.mockRejectedValue(new Error("db error")); + + await handleDocumentUpdate(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: ErrorCode.INTERNAL_ERROR, + }); + }); +}); + diff --git a/src/tests/services/socket-handlers.handlePeersList.test.ts b/src/tests/services/socket-handlers.handlePeersList.test.ts new file mode 100644 index 0000000..67b5c3a --- /dev/null +++ b/src/tests/services/socket-handlers.handlePeersList.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { handlePeersList, getRoomName } from "../../services/socket-handlers"; +// import { PeersListArgs } from "../../types"; +import type { AppServer, AppSocket, PeersListArgs } from "../../types"; + +function createFakeIO(fetchSocketsResponse: any[] = []): AppServer { + const fetchSocketsMock = vi.fn().mockResolvedValue(fetchSocketsResponse); + + return { + in: vi.fn(() => ({ + fetchSockets: fetchSocketsMock, + })), + } as unknown as AppServer; +} + +function createFakeSocket( + broadcastOperator?: { emit: ReturnType}, + dataOverrides?: Partial<{ + authenticated: boolean; + documentId: string; + sessionDid: string; + role: "owner" | "editor"; + }> +): AppSocket { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + const defaultData = { + authenticated: true, + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "owner" as const, + }; + const data = { ...defaultData, ...dataOverrides }; + + return { + id: "socket-1", + data, + to: vi.fn(() => toReturn), + } as unknown as AppSocket; +} + +describe("tests peers list handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }) + + it("returns early when socket is not authenticated", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(undefined, { authenticated: false }); + const fakeArgs: PeersListArgs = {}; + const fakeCallback= vi.fn(); + + await handlePeersList(fakeIO, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeIO.in).not.toHaveBeenCalled(); + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns early when documentId is missing in socket data", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(undefined, { documentId: "" }); + const fakeArgs: PeersListArgs = {}; + const fakeCallback= vi.fn(); + + await handlePeersList(fakeIO, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeIO.in).not.toHaveBeenCalled(); + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns early when sessionDid is missing in socket data", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(undefined, { sessionDid: "" }); + const fakeArgs: PeersListArgs = {}; + const fakeCallback = vi.fn(); + + await handlePeersList(fakeIO, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeIO.in).not.toHaveBeenCalled(); + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated or session not found", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns 500 when fetchSockets throws", async () => { + const fetchSocketsMock = vi.fn().mockRejectedValue(new Error("fetch failed")); + const fakeIO = { + in: vi.fn(() => ({ + fetchSockets: fetchSocketsMock, + })), + } as unknown as AppServer; + const fakeSocket = createFakeSocket(); + const fakeArgs: PeersListArgs = {}; + const fakeCallback = vi.fn(); + + await handlePeersList(fakeIO, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: "INTERNAL_ERROR", + }); + }); + + it("returns peers list successfully", async () => { + const fetchSocketsResponse = [ + { id: "socket-id-1" }, + { id: "socket-id-2" } + ]; + const fakeIO = createFakeIO(fetchSocketsResponse); + const fakeSocket = createFakeSocket(); + const fakeArgs: PeersListArgs = {}; + const fakeCallback = vi.fn(); + + await handlePeersList(fakeIO, fakeSocket, fakeArgs, fakeCallback); + + const documentId = fakeArgs.documentId || fakeSocket.data.documentId; + const roomName = getRoomName(documentId, fakeSocket.data.sessionDid); + + expect(fakeIO.in).toHaveBeenCalledWith(roomName); + + const peersData = ["socket-id-1", "socket-id-2"]; + expect(fakeCallback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + peers: peersData, + }, + }) + }); +}); \ No newline at end of file diff --git a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts new file mode 100644 index 0000000..3725084 --- /dev/null +++ b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts @@ -0,0 +1,233 @@ +import { describe, vi, it, expect, beforeEach } from "vitest"; +import { handleUpdateHistory } from "../../services/socket-handlers"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import type { AppSocket, DocumentUpdate, UpdateHistoryArgs } from "../../types"; + +function createFakeSocket( + broadcastOperator?: { emit: ReturnType }, + dataOverrides?: Partial<{ + authenticated: boolean; + documentId: string; + sessionDid: string; + role: "owner" | "editor"; + }> +): AppSocket { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + const defaultData = { + authenticated: true, + documentId: "test-document-id", + sessionDid: "test-session-did", + role: "owner" as const, + }; + const data = { ...defaultData, ...dataOverrides }; + + return { + id: "socket-1", + data, + to: vi.fn(() => toReturn), + } as unknown as AppSocket; +} + +describe("updateHistory", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const fakeMongodbStore = { + getUpdatesByDocument: vi.fn(), + countUpdatesByDocument: vi.fn(), + }; + + const deps: SocketHandlerDeps = { + authService: {} as any, + sessionManager: {} as any, + mongodbStore: fakeMongodbStore as any, + }; + + it("returns early when not authenticated", async () => { + const fakeSocket: AppSocket = createFakeSocket(undefined, { authenticated: false }); + const fakeArgs: UpdateHistoryArgs = { + documentId: "test-document-id", + }; + const fakeCallback = vi.fn(); + + await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns early when documentId is empty in socket data", async () => { + const fakeSocket: AppSocket = createFakeSocket(undefined, { documentId: "" }); + const fakeArgs: UpdateHistoryArgs = {}; + const fakeCallback = vi.fn(); + + await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns early when sessionDid is empty in socket data", async () => { + const fakeSocket: AppSocket = createFakeSocket(undefined, { sessionDid: "" }); + const fakeArgs: UpdateHistoryArgs = { + documentId: "test-document-id", + }; + const fakeCallback = vi.fn(); + + await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 401, + error: "Not authenticated", + errorCode: "NOT_AUTHENTICATED", + }); + }); + + it("returns update history successfully, with fallback argument values", async () => { + const fakeSocket: AppSocket = createFakeSocket(undefined, { + documentId: "test-document-id", + }); + const fakeArgs: UpdateHistoryArgs = { + documentId: "test-document-id", + }; + const fakeCallback = vi.fn(); + const fakeResponse: DocumentUpdate[] = []; + const documentId = fakeArgs.documentId || fakeSocket.data.documentId; + + fakeMongodbStore.getUpdatesByDocument.mockResolvedValue(fakeResponse); + fakeMongodbStore.countUpdatesByDocument.mockResolvedValue(fakeResponse.length); + + await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeMongodbStore.getUpdatesByDocument).toHaveBeenCalledWith( + { + documentId, + sessionDid: fakeSocket.data.sessionDid, + }, + { + committed: undefined, + } + ); + expect(fakeMongodbStore.countUpdatesByDocument).toHaveBeenCalledWith( + { + documentId, + sessionDid: fakeSocket.data.sessionDid, + }, + { committed: undefined }, + ); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + history: [], + total: fakeResponse.length, + }, + }); + }); + + it("returns update history successfully with proper argument values set", async () => { + const fakeSocket: AppSocket = createFakeSocket(); + const fakeArgs: UpdateHistoryArgs = { + documentId: "test-document-id", + limit: 1000, + offset: 0, + filters: { + committed: false, + }, + sort: "desc", + }; + const fakeCallback = vi.fn(); + const fakeResponse: DocumentUpdate[] = [ + { + id: "test-id", + documentId: "test-document-id", + data: "test-encrypted-data", + updateType: "yjs_update", + committed: false, + commitCid: null, + createdAt: 1772181495470, + sessionDid: "test-session-did", + }, + ]; + const documentId = fakeArgs.documentId || fakeSocket.data.documentId; + + fakeMongodbStore.getUpdatesByDocument.mockResolvedValue(fakeResponse); + fakeMongodbStore.countUpdatesByDocument.mockResolvedValue(fakeResponse.length); + + await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeMongodbStore.getUpdatesByDocument).toHaveBeenCalledWith( + { + documentId, + sessionDid: fakeSocket.data.sessionDid, + }, + { + committed: fakeArgs.filters?.committed, + } + ); + expect(fakeMongodbStore.countUpdatesByDocument).toHaveBeenCalledWith( + { + documentId, + sessionDid: fakeSocket.data.sessionDid, + }, + { committed: fakeArgs.filters?.committed }, + ); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: true, + statusCode: 200, + data: { + history: fakeResponse, + total: fakeResponse.length, + }, + }); + }); + + it("returns 500 due to db operation error", async () => { + const fakeSocket: AppSocket = createFakeSocket(); + const fakeArgs: UpdateHistoryArgs = { + documentId: "test-document-id", + limit: 1000, + offset: 0, + filters: { + committed: false, + }, + sort: "desc", + }; + const fakeCallback = vi.fn(); + const documentId = fakeArgs.documentId || fakeSocket.data.documentId; + + fakeMongodbStore.getUpdatesByDocument.mockRejectedValue(new Error("db error")); + fakeMongodbStore.countUpdatesByDocument.mockResolvedValue(0); + + await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); + + expect(fakeMongodbStore.getUpdatesByDocument).toHaveBeenCalledWith( + { + documentId, + sessionDid: fakeSocket.data.sessionDid, + }, + { + committed: false, + } + ); + + expect(fakeCallback).toHaveBeenCalledWith({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: "INTERNAL_ERROR", + }); + }); +}); \ No newline at end of file diff --git a/src/tests/services/socket-handlers.terminateSession.test.ts b/src/tests/services/socket-handlers.terminateSession.test.ts new file mode 100644 index 0000000..4343003 --- /dev/null +++ b/src/tests/services/socket-handlers.terminateSession.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleTerminateSession } from "../../services/socket-handlers"; +import type { AppServer, AppSocket } from "../../types"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; + +function createFakeIO(fetchSocketsResponse: any[] = []): AppServer { + const fetchSocketsMock = vi.fn().mockResolvedValue(fetchSocketsResponse); + + return { + in: vi.fn(() => ({ + fetchSockets: fetchSocketsMock, + })), + } as unknown as AppServer; +} + +/** + * Fake socket for handler tests. + * If you pass a broadcastOperator, socket.to(room) will return it so you can assert + * on broadcastOperator.emit(event, payload) in the test. + */ +function createFakeSocket(broadcastOperator?: { emit: ReturnType }) { + const toReturn = broadcastOperator ?? { emit: vi.fn() }; + const socket = { + id: "socket-1", + data: { + authenticated: true, + documentId: "doc-1", + sessionDid: "session-1", + role: "owner" as const, + }, + to: vi.fn(() => toReturn), + } as unknown as AppSocket; + return socket; +} + +describe("handleTerminateSession", () => { + const fakeAuthService = { + verifyOwnerToken: vi.fn<[], Promise>(), + }; + const fakeSessionManager = { + getSession: vi.fn(), + deactivateSession: vi.fn(), + terminateSession: vi.fn(), + }; + const fakeMongoDBStore = {} as any; + const deps: SocketHandlerDeps = { + authService: fakeAuthService as any, + sessionManager: fakeSessionManager as any, + mongodbStore: fakeMongoDBStore, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 when sessionDid is empty", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "", + ownerToken: "test-owner-token", + ownerAddress: "test-owner-address", + contractAddress: "test-contract-address", + }; + const callback = vi.fn(); + const callbackResponse = { + status: false, + statusCode: 400, + error: "Session DID is required", + errorCode: "SESSION_DID_MISSING", + }; + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + expect(callback).toHaveBeenCalledWith(callbackResponse); + expect(fakeSessionManager.getSession).not.toHaveBeenCalled(); + }); + + it("returns 400 when contract or owner address format is invalid", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "test-session-did", + ownerToken: "test-owner-token", + ownerAddress: "not-a-valid-address", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + const fakeSessionResponse = { ownerDid: "fake-owner-did", sessionDid: fakeArgs.sessionDid }; + fakeSessionManager.getSession.mockResolvedValue(fakeSessionResponse); + + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.getSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid + ); + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 400, + error: "Invalid contract address or owner address format", + errorCode: "INVALID_ADDRESS", + }); + expect(fakeAuthService.verifyOwnerToken).not.toHaveBeenCalled(); + }); + + it("returns 404 when session is not found", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "test-session-did", + ownerToken: "test-owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockResolvedValue(undefined); + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + + const callbackResponse = { + status: false, + statusCode: 404, + error: "Session not found", + errorCode: "SESSION_NOT_FOUND", + }; + expect(fakeSessionManager.getSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid + ); + expect(callback).toHaveBeenCalledWith(callbackResponse); + }); + + it("returns 401 when ownerDid does not match session owner", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "test-session-did", + ownerToken: "test-owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + const fakeSessionResponse = { ownerDid: "fake-owner-did" }; + const ownerDidResponse = "different-owner-did"; + fakeSessionManager.getSession.mockResolvedValue(fakeSessionResponse); + fakeAuthService.verifyOwnerToken.mockResolvedValue(ownerDidResponse); + + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.getSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid + ); + + expect(fakeAuthService.verifyOwnerToken).toHaveBeenCalledWith( + fakeArgs.ownerToken, + fakeArgs.contractAddress, + fakeArgs.ownerAddress, + ); + + const callbackResponse = { + status: false, + statusCode: 401, + error: "Unauthorized", + errorCode: "AUTH_TOKEN_INVALID", + }; + expect(callback).toHaveBeenCalledWith(callbackResponse); + }); + + it("returns 200 when session is terminated", async () => { + // Mock: io.in(roomName).fetchSockets() will resolve to this array + const fetchSocketsResponse = [ + { + id: "peer-1", + data: { authenticated: true }, + leave: vi.fn(), + }, + ]; + /** + * To mock this + * const sockets = await io.in(roomName).fetchSockets(); + * such that, the fetchSockets() returns a mock value that we set. + */ + const fakeIO = createFakeIO(fetchSocketsResponse); + + /** + * Likewise, we want to mock + * socket.to(roomName).emit() calls + * socket.to(roomName) returns a broadcast operator, which has an emit function on itself. + * So, we want to mock all of that. + */ + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "test-session-did", + ownerToken: "test-owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + const fakeSessionResponse = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "match-owner-did", + }; + const ownerDidResponse = "match-owner-did"; + + // set the mock return values + fakeSessionManager.getSession.mockResolvedValue(fakeSessionResponse); + fakeAuthService.verifyOwnerToken.mockResolvedValue(ownerDidResponse); + fakeSessionManager.terminateSession.mockResolvedValue(undefined); + + // now actually call the function + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.getSession).toHaveBeenCalledOnce(); + expect(fakeSessionManager.getSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeArgs.sessionDid + ); + + expect(fakeAuthService.verifyOwnerToken).toHaveBeenCalledOnce(); + expect(fakeAuthService.verifyOwnerToken).toHaveBeenCalledWith( + fakeArgs.ownerToken, + fakeArgs.contractAddress, + fakeArgs.ownerAddress, + ); + + const roomName = `session::${fakeArgs.documentId}__${fakeSessionResponse.sessionDid}`; + + expect(fakeSocket.to).toHaveBeenCalledWith(roomName); + expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/session/terminated", { + roomId: fakeArgs.documentId, + }); + + expect(fetchSocketsResponse[0].leave).toHaveBeenCalledWith(roomName); + expect(fetchSocketsResponse[0].data.authenticated).toBe(false); + expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSessionResponse.sessionDid + ); + + const callbackResponse = { + status: true, + statusCode: 200, + data: { message: "Session terminated" }, + }; + expect(callback).toHaveBeenCalledWith(callbackResponse); + }); + + it("returns 500 when an unexpected error occurs in terminate session handler", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket(); + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "test-session-did", + ownerToken: "test-owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + fakeSessionManager.getSession.mockRejectedValue(new Error("db error")); + + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: "INTERNAL_ERROR", + }); + }); +}); + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b8d0f7f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + enabled: true, + reporter: ["lcov"], + }, + environment: "node", + include: ["src/tests/**/*.test.ts", "tests/**/*.test.ts"], + globals: true, + }, +}); +