diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a92680..9da92e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -52,6 +52,9 @@ }, "devDependencies": { "@eslint/js": "^9.25.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.0", "@types/node": "^24.0.1", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", @@ -60,12 +63,22 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "jsdom": "^25.0.0", + "msw": "^2.7.0", "tw-animate-css": "^1.3.4", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^3.0.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -79,6 +92,172 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "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", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@date-fns/tz": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", @@ -768,6 +947,94 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -878,6 +1145,24 @@ "node": ">=6.0.0" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.2.tgz", + "integrity": "sha512-7G0Uf0yK3f2bjElBLGHIQzgRgMESczOMyYVasq1XK8P5HaXtlW4eQhz9MBL+TQILZLaruq+ClGId+hH0w4jvWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -916,6 +1201,31 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2805,6 +3115,115 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/crossfilter": { "version": "0.0.38", "resolved": "https://registry.npmjs.org/@types/crossfilter/-/crossfilter-0.0.38.tgz", @@ -3064,6 +3483,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3125,7 +3551,6 @@ "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.8.0" } @@ -3142,7 +3567,6 @@ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3153,11 +3577,17 @@ "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -3223,7 +3653,6 @@ "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", @@ -3449,13 +3878,127 @@ "vite": "^4 || ^5 || ^6 || ^7.0.0-beta.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3473,6 +4016,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3490,6 +4043,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3525,6 +4088,33 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3556,6 +4146,30 @@ "node": ">=8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3566,6 +4180,23 @@ "node": ">=6" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3589,6 +4220,16 @@ "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", "license": "ISC" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -3622,6 +4263,49 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3651,6 +4335,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -3667,6 +4364,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3691,12 +4402,40 @@ "@ranfdev/deepobj": "1.0.2" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csscolorparser": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", "license": "MIT" }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -4021,7 +4760,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4106,6 +4844,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -4146,6 +4898,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4162,6 +4931,26 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -4177,12 +4966,42 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/earcut": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz", "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", @@ -4196,6 +5015,75 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", @@ -4236,6 +5124,16 @@ "@esbuild/win32-x64": "0.25.5" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -4255,7 +5153,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4418,6 +5315,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", @@ -4428,6 +5335,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4553,6 +5470,23 @@ "dev": true, "license": "ISC" }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -4567,12 +5501,57 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/geojson-vt": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", "license": "ISC" }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4582,6 +5561,20 @@ "node": ">=6" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gl-matrix": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", @@ -4614,6 +5607,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4627,6 +5633,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", + "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/grid-index": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", @@ -4643,6 +5659,96 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -4712,6 +5818,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -4731,6 +5847,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4744,6 +5870,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4754,6 +5887,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4770,6 +5910,14 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -4783,6 +5931,47 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5094,6 +6283,20 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/lucide-react": { "version": "0.515.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.515.0.tgz", @@ -5103,6 +6306,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -5176,6 +6390,16 @@ "integrity": "sha512-Qz9RgWuO9l8lT+Y9xvbzhPT2efIUIFd69N7eF7tJ9lnQl0iLj1M7peK7IoUGZL9DJHw9XftqLreccfxcQgYLxA==", "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5200,6 +6424,39 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5256,12 +6513,100 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.12.10", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", + "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/murmurhash-js": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", "license": "MIT" }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5287,12 +6632,18 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/oidc-client-ts": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.2.1.tgz", "integrity": "sha512-hS5AJ5s/x4bXhHvNJT1v+GGvzHUwdRWqNQQbSrp10L1IRmzfRGKQ3VWN3dstJb+oF3WtAyKezwD2+dTEIyBiAA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "jwt-decode": "^4.0.0" }, @@ -5318,6 +6669,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5363,6 +6721,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5383,6 +6754,30 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pbf": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", @@ -5459,6 +6854,36 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", @@ -5507,7 +6932,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5538,7 +6962,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -5551,7 +6974,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.58.1.tgz", "integrity": "sha512-Lml/KZYEEFfPhUVgE0RdCVpnC4yhW+PndRhbiTtdvSlQTL8IfVR+iQkBjLIvmmc6+GGoVeM11z37ktKFPAb0FA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5563,6 +6985,14 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-oidc-context": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.0.tgz", @@ -5655,6 +7085,30 @@ "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -5674,6 +7128,13 @@ "protocol-buffers-schema": "^3.3.1" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5736,6 +7197,13 @@ "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "license": "MIT" }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5772,6 +7240,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -5823,6 +7304,26 @@ "node": ">=8" } }, + "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", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5838,6 +7339,78 @@ "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "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/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5851,6 +7424,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/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/supercluster": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -5873,6 +7466,26 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", @@ -5915,6 +7528,20 @@ "node": ">=18" } }, + "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", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", @@ -5950,7 +7577,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5958,12 +7584,62 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5977,6 +7653,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -6019,13 +7721,28 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6064,6 +7781,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -6122,7 +7849,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6192,6 +7918,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.4.6", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", @@ -6211,7 +7960,92 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", - "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "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/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6230,6 +8064,67 @@ "pbf": "^3.2.1" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6246,6 +8141,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", @@ -6256,6 +8168,70 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -6265,6 +8241,35 @@ "node": ">=18" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6278,6 +8283,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.67", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", diff --git a/frontend/package.json b/frontend/package.json index bbc5774..4544b91 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,10 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@hookform/resolvers": "^5.1.1", @@ -65,6 +68,12 @@ "tw-animate-css": "^1.3.4", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vitest": "^3.0.0", + "@testing-library/react": "^16.3.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/user-event": "^14.6.0", + "jsdom": "^25.0.0", + "msw": "^2.7.0" } } diff --git a/frontend/src/__tests__/e2e/filter-stream-display.test.tsx b/frontend/src/__tests__/e2e/filter-stream-display.test.tsx new file mode 100644 index 0000000..ab1ca74 --- /dev/null +++ b/frontend/src/__tests__/e2e/filter-stream-display.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { StreamingProgressBar } from '@/components/StreamingProgressBar'; +import { ListView } from '@/components/ListView'; +import { createMockFeatureCollection } from '@/__tests__/helpers'; +import type { StreamingProgress } from '@/services'; + +// Mock react-virtuoso for ListView +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ data, itemContent }: { data: unknown[]; itemContent: (index: number, item: unknown) => React.ReactNode }) => ( +
+ {data.map((item, index) => ( +
{itemContent(index, item)}
+ ))} +
+ ), +})); + +describe('Filter → Stream → Display Integration', () => { + it('shows StreamingProgressBar when isLoading is true', () => { + render( + , + ); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('ListView shows listings after stream completes', () => { + const data = createMockFeatureCollection(5); + render(); + expect(screen.getByText(/5\s*properties/)).toBeInTheDocument(); + }); + + it('listing count updates when features change', () => { + const data2 = createMockFeatureCollection(2); + const data7 = createMockFeatureCollection(7); + + const { rerender } = render(); + expect(screen.getByText(/2\s*properties/)).toBeInTheDocument(); + + rerender(); + expect(screen.getByText(/7\s*properties/)).toBeInTheDocument(); + }); + + it('empty state renders when no features', () => { + const empty = createMockFeatureCollection(0); + render(); + expect(screen.getByText(/0\s*properties/)).toBeInTheDocument(); + }); + + it('StreamingProgressBar hides when loading completes', () => { + const { rerender, container } = render( + , + ); + expect(screen.getByText('Loading listings...')).toBeInTheDocument(); + + rerender( + , + ); + // Component returns null when not loading + expect(container.firstChild).toBeNull(); + }); + + it('progress updates are reflected in StreamingProgressBar', () => { + const progress1: StreamingProgress = { count: 10, total: 100 }; + const progress2: StreamingProgress = { count: 75, total: 100 }; + + const { rerender, container } = render( + , + ); + let bar = container.querySelector('.bg-primary.transition-all'); + expect(bar).toHaveStyle({ width: '10%' }); + + rerender( + , + ); + bar = container.querySelector('.bg-primary.transition-all'); + expect(bar).toHaveStyle({ width: '75%' }); + }); +}); diff --git a/frontend/src/__tests__/helpers.ts b/frontend/src/__tests__/helpers.ts new file mode 100644 index 0000000..8f82932 --- /dev/null +++ b/frontend/src/__tests__/helpers.ts @@ -0,0 +1,89 @@ +import type { AuthUser } from '@/auth/types'; +import type { PropertyProperties, PropertyFeature, GeoJSONFeatureCollection, TaskState } from '@/types'; + +/** + * Create a mock AuthUser for testing + */ +export function mockUser(overrides: Partial = {}): AuthUser { + return { + sub: 'test-user-id', + email: 'test@example.com', + name: 'Test User', + accessToken: 'test-access-token', + provider: 'oidc', + ...overrides, + }; +} + +/** + * Create a mock PropertyProperties object + */ +export function createMockProperty(overrides: Partial = {}): PropertyProperties { + return { + url: 'https://www.rightmove.co.uk/properties/12345678', + city: 'London', + country: 'UK', + qm: 65, + qmprice: 38.46, + total_price: 2500, + rooms: 2, + agency: 'Test Agency', + available_from: '2024-06-01', + last_seen: new Date().toISOString(), + photo_thumbnail: 'https://example.com/photo.jpg', + price_history: [], + listing_type: 'RENT', + ...overrides, + }; +} + +/** + * Create a mock GeoJSON Feature + */ +export function createMockFeature(overrides: Partial = {}): PropertyFeature { + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-0.1276, 51.5074], + }, + properties: createMockProperty(overrides), + }; +} + +/** + * Create a mock FeatureCollection + */ +export function createMockFeatureCollection( + count: number = 3, + propertyOverrides: Partial = {}, +): GeoJSONFeatureCollection { + return { + type: 'FeatureCollection', + features: Array.from({ length: count }, (_, i) => + createMockFeature({ + url: `https://www.rightmove.co.uk/properties/${1000 + i}`, + total_price: 1500 + i * 500, + rooms: (i % 3) + 1, + qm: 30 + i * 15, + ...propertyOverrides, + }), + ), + }; +} + +/** + * Create a mock TaskState + */ +export function createMockTaskState(overrides: Partial = {}): TaskState { + return { + task_id: 'test-task-123', + status: 'STARTED', + progress: 0.5, + processed: 50, + total: 100, + phase: 'fetching', + message: 'Fetching listings', + ...overrides, + }; +} diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts new file mode 100644 index 0000000..e8f9698 --- /dev/null +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -0,0 +1,77 @@ +import { http, HttpResponse } from 'msw'; + +export const handlers = [ + // Health check + http.get('/api/status', () => { + return HttpResponse.json({ status: 'OK' }); + }), + + // Get listings + http.get('/api/listing', () => { + return HttpResponse.json({ listings: [] }); + }), + + // Get listing GeoJSON + http.get('/api/listing_geojson', () => { + return HttpResponse.json({ + type: 'FeatureCollection', + features: [], + }); + }), + + // Stream listing GeoJSON + http.get('/api/listing_geojson/stream', () => { + const lines = [ + JSON.stringify({ type: 'metadata', batch_size: 50, total_expected: 0, cached: false }), + JSON.stringify({ type: 'complete', total: 0 }), + ].join('\n') + '\n'; + + return new HttpResponse(lines, { + headers: { 'Content-Type': 'application/x-ndjson' }, + }); + }), + + // Refresh listings + http.post('/api/refresh_listings', () => { + return HttpResponse.json({ task_id: 'test-task-123', message: 'Task started' }); + }), + + // Task status + http.get('/api/task_status', () => { + return HttpResponse.json({ + task_id: 'test-task-123', + status: 'PENDING', + result: null, + progress: null, + processed: null, + total: null, + message: null, + error: null, + traceback: null, + }); + }), + + // Tasks for user + http.get('/api/tasks_for_user', () => { + return HttpResponse.json([]); + }), + + // Cancel task + http.post('/api/cancel_task', () => { + return HttpResponse.json({ success: true, message: 'Task cancelled' }); + }), + + // Clear all tasks + http.post('/api/clear_all_tasks', () => { + return HttpResponse.json({ success: true, count: 0, message: 'Cleared 0 tasks' }); + }), + + // Districts + http.get('/api/get_districts', () => { + return HttpResponse.json({ + London: 'REGION^87490', + Westminster: 'REGION^93980', + Camden: 'REGION^93941', + }); + }), +]; diff --git a/frontend/src/__tests__/mocks/server.ts b/frontend/src/__tests__/mocks/server.ts new file mode 100644 index 0000000..e52fee0 --- /dev/null +++ b/frontend/src/__tests__/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts new file mode 100644 index 0000000..1692a91 --- /dev/null +++ b/frontend/src/__tests__/setup.ts @@ -0,0 +1,59 @@ +import '@testing-library/jest-dom/vitest'; + +// Polyfill ResizeObserver for jsdom (used by Radix UI components) +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; +} + +// Mock mapbox-gl (requires WebGL which jsdom doesn't support) +vi.mock('mapbox-gl', () => ({ + default: { + Map: vi.fn(() => ({ + on: vi.fn(), + off: vi.fn(), + remove: vi.fn(), + addSource: vi.fn(), + addLayer: vi.fn(), + getSource: vi.fn(), + getLayer: vi.fn(), + removeLayer: vi.fn(), + removeSource: vi.fn(), + setLayoutProperty: vi.fn(), + setPaintProperty: vi.fn(), + flyTo: vi.fn(), + fitBounds: vi.fn(), + resize: vi.fn(), + getCanvas: vi.fn(() => ({ style: {} })), + })), + NavigationControl: vi.fn(), + Popup: vi.fn(() => ({ + setLngLat: vi.fn().mockReturnThis(), + setHTML: vi.fn().mockReturnThis(), + setDOMContent: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn(), + })), + Marker: vi.fn(() => ({ + setLngLat: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn(), + })), + LngLatBounds: vi.fn(() => ({ + extend: vi.fn().mockReturnThis(), + isEmpty: vi.fn(() => false), + })), + }, + Map: vi.fn(), + NavigationControl: vi.fn(), +})); + +// Mock import.meta.env +if (!import.meta.env) { + // @ts-expect-error setting up test env + import.meta.env = {}; +} +import.meta.env.VITE_MAPBOX_TOKEN = 'test-token'; diff --git a/frontend/src/components/__tests__/FilterPanel.test.tsx b/frontend/src/components/__tests__/FilterPanel.test.tsx new file mode 100644 index 0000000..d61e507 --- /dev/null +++ b/frontend/src/components/__tests__/FilterPanel.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { FilterPanel, ListingType, Metric, DEFAULT_FILTER_VALUES } from '@/components/FilterPanel'; + +// Mock the POIManager to avoid its dependencies +vi.mock('@/components/POIManager', () => ({ + POIManager: () =>
POIManager
, +})); + +const defaultProps = { + onSubmit: vi.fn(), + currentMetric: Metric.qmprice, + isLoading: false, +}; + +describe('FilterPanel', () => { + it('renders listing type tabs (Rent and Buy)', () => { + render(); + expect(screen.getByText('Rent')).toBeInTheDocument(); + expect(screen.getByText('Buy')).toBeInTheDocument(); + }); + + it('renders price range slider', () => { + render(); + expect(screen.getByText(/Price/)).toBeInTheDocument(); + }); + + it('renders bedroom range slider', () => { + render(); + expect(screen.getByText('Bedrooms')).toBeInTheDocument(); + }); + + it('calls onSubmit when Apply Filters is clicked', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + + const applyBtn = screen.getByText('Apply Filters'); + await user.click(applyBtn); + + expect(onSubmit).toHaveBeenCalledWith('visualize', expect.objectContaining({ + listing_type: ListingType.RENT, + })); + }); + + it('disables submit button when loading', () => { + render(); + const applyBtn = screen.getByText('Loading...'); + expect(applyBtn.closest('button')).toBeDisabled(); + }); + + it('shows furnish types only for rent', async () => { + const user = userEvent.setup(); + render(); + + // Open the Advanced Filters accordion + const advancedTrigger = screen.getByText('Advanced Filters'); + await user.click(advancedTrigger); + + // Furnish options should be visible for RENT + expect(screen.getByText('Furnished')).toBeInTheDocument(); + }); + + it('renders min size input', () => { + render(); + expect(screen.getByText(/Min Size/)).toBeInTheDocument(); + }); + + it('renders last seen days in advanced filters', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Advanced Filters')); + expect(screen.getByText(/Last Seen/)).toBeInTheDocument(); + }); + + it('initializes with default values', () => { + render(); + // Default listing type is RENT, so Rent tab should be active + // The Rent tab should exist + expect(screen.getByText('Rent')).toBeInTheDocument(); + }); + + it('renders available from picker in advanced for rent', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Advanced Filters')); + expect(screen.getByText('Available From')).toBeInTheDocument(); + }); + + it('renders district input in advanced filters', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Advanced Filters')); + expect(screen.getByText('District')).toBeInTheDocument(); + }); + + it('renders price per sqm fields in advanced filters', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText('Advanced Filters')); + expect(screen.getByText('Min £/m²')).toBeInTheDocument(); + expect(screen.getByText('Max £/m²')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/Header.test.tsx b/frontend/src/components/__tests__/Header.test.tsx new file mode 100644 index 0000000..3174862 --- /dev/null +++ b/frontend/src/components/__tests__/Header.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Header } from '@/components/Header'; +import { mockUser, createMockTaskState } from '@/__tests__/helpers'; + +// Mock auth services to prevent actual calls +vi.mock('@/auth/authService', () => ({ + logout: vi.fn(async () => {}), +})); +vi.mock('@/auth/passkeyService', () => ({ + clearPasskeyUser: vi.fn(), +})); + +// Mock HealthIndicator to avoid async health check calls +vi.mock('@/components/HealthIndicator', () => ({ + HealthIndicator: () =>
HealthIndicator
, +})); + +const defaultProps = { + user: mockUser({ email: 'alice@example.com' }), + tasks: {} as Record, + activeTaskId: null, + isConnected: true, + onCancelTask: vi.fn(async () => true), + onClearAllTasks: vi.fn(async () => true), +}; + +describe('Header', () => { + it('renders user email', () => { + render(
); + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + }); + + it('renders Logout button', () => { + render(
); + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + it('calls logout on button click', async () => { + const user = userEvent.setup(); + const { logout } = await import('@/auth/authService'); + + render(
); + await user.click(screen.getByText('Logout')); + + expect(logout).toHaveBeenCalled(); + }); + + it('renders health indicator', () => { + render(
); + expect(screen.getByTestId('health-indicator')).toBeInTheDocument(); + }); + + it('renders task indicator when tasks are present', () => { + const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) }; + const { container } = render( +
, + ); + // TaskIndicator renders animate-spin for running tasks + expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('shows active filter count badge', () => { + render( +
, + ); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('renders brand name', () => { + render(
); + expect(screen.getByText('Wrongmove')).toBeInTheDocument(); + }); + + it('shows mobile filter toggle when enabled', () => { + const onToggle = vi.fn(); + const { container } = render( +
, + ); + // The filter toggle button has sm:hidden class + const filterButton = container.querySelector('.sm\\:hidden'); + expect(filterButton).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/HealthIndicator.test.tsx b/frontend/src/components/__tests__/HealthIndicator.test.tsx new file mode 100644 index 0000000..3a21618 --- /dev/null +++ b/frontend/src/components/__tests__/HealthIndicator.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, waitFor, act } from '@testing-library/react'; +import { HealthIndicator } from '@/components/HealthIndicator'; + +vi.mock('@/services', async () => { + const actual = await vi.importActual('@/services'); + return { ...actual, checkBackendHealth: vi.fn() }; +}); + +import { checkBackendHealth } from '@/services'; +const mockCheck = vi.mocked(checkBackendHealth); + +describe('HealthIndicator', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('shows Checking... initially', () => { + // Never resolve so the component stays in "checking" state + mockCheck.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByText('Checking...')).toBeInTheDocument(); + }); + + it('shows Connected when healthy', async () => { + mockCheck.mockResolvedValue({ status: 'healthy', latencyMs: 50 }); + render(); + await waitFor(() => { + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + }); + + it('shows Disconnected when unhealthy', async () => { + mockCheck.mockResolvedValue({ status: 'unhealthy', error: 'timeout' }); + render(); + await waitFor(() => { + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + }); + }); + + it('checks health periodically', async () => { + vi.useFakeTimers(); + mockCheck.mockResolvedValue({ status: 'healthy', latencyMs: 10 }); + + render(); + + // Initial call + expect(mockCheck).toHaveBeenCalledTimes(1); + + // Advance past one interval + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(mockCheck).toHaveBeenCalledTimes(2); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(mockCheck).toHaveBeenCalledTimes(3); + }); +}); diff --git a/frontend/src/components/__tests__/ListView.test.tsx b/frontend/src/components/__tests__/ListView.test.tsx new file mode 100644 index 0000000..ecbc4d5 --- /dev/null +++ b/frontend/src/components/__tests__/ListView.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ListView } from '@/components/ListView'; +import { createMockFeatureCollection, createMockFeature } from '@/__tests__/helpers'; + +// Mock react-virtuoso since it needs a real DOM with dimensions +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ data, itemContent }: { data: unknown[]; itemContent: (index: number, item: unknown) => React.ReactNode }) => ( +
+ {data.map((item, index) => ( +
{itemContent(index, item)}
+ ))} +
+ ), +})); + +describe('ListView', () => { + it('renders listing count', () => { + const data = createMockFeatureCollection(3); + render(); + expect(screen.getByText(/3\s*properties/)).toBeInTheDocument(); + }); + + it('renders property cards for each feature', () => { + const data = createMockFeatureCollection(3); + render(); + // Each PropertyCard renders a price with £ sign + const prices = screen.getAllByText(/£/); + expect(prices.length).toBeGreaterThanOrEqual(3); + }); + + it('renders sort controls', () => { + const data = createMockFeatureCollection(1); + render(); + expect(screen.getByText('Sort:')).toBeInTheDocument(); + expect(screen.getByText('Price')).toBeInTheDocument(); + expect(screen.getByText('£/m²')).toBeInTheDocument(); + expect(screen.getByText('Size')).toBeInTheDocument(); + }); + + it('highlights selected property', () => { + const feature = createMockFeature({ url: 'https://rightmove.co.uk/selected' }); + const data = { type: 'FeatureCollection' as const, features: [feature] }; + const { container } = render( + , + ); + expect(container.querySelector('.ring-2')).toBeInTheDocument(); + }); + + it('shows empty state for zero features', () => { + const data = createMockFeatureCollection(0); + render(); + expect(screen.getByText(/0\s*properties/)).toBeInTheDocument(); + }); + + it('changes sort order when clicking a sort button', async () => { + const user = userEvent.setup(); + const data = createMockFeatureCollection(3); + render(); + + const priceButton = screen.getByText('Price'); + await user.click(priceButton); + + // The button should now be active (secondary variant) + // Clicking toggles sort - this doesn't crash, which validates the sort logic + await user.click(priceButton); + }); + + it('uses compact variant for property cards', () => { + const data = createMockFeatureCollection(1); + const { container } = render(); + // Compact cards have the flex gap-3 p-3 layout + expect(container.querySelector('.flex.gap-3.p-3')).toBeInTheDocument(); + }); + + it('calculates avg price per sqm for deal badges', () => { + // Create properties where one is clearly a good deal + const features = [ + createMockFeature({ qmprice: 20, total_price: 1000 }), + createMockFeature({ url: 'https://rightmove.co.uk/2', qmprice: 50, total_price: 3000 }), + createMockFeature({ url: 'https://rightmove.co.uk/3', qmprice: 50, total_price: 3000 }), + ]; + // avg = 40, so 20 < 40*0.9=36 → Good deal + const data = { type: 'FeatureCollection' as const, features }; + render(); + expect(screen.getByText('Good deal')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/PropertyCard.test.tsx b/frontend/src/components/__tests__/PropertyCard.test.tsx new file mode 100644 index 0000000..72e6131 --- /dev/null +++ b/frontend/src/components/__tests__/PropertyCard.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PropertyCard } from '@/components/PropertyCard'; +import { createMockProperty } from '@/__tests__/helpers'; + +describe('PropertyCard', () => { + it('renders rent price with /mo suffix', () => { + const property = createMockProperty({ listing_type: 'RENT', total_price: 2500 }); + render(); + expect(screen.getByText('/mo')).toBeInTheDocument(); + expect(screen.getByText(/2,500/)).toBeInTheDocument(); + }); + + it('renders buy price without /mo suffix', () => { + const property = createMockProperty({ listing_type: 'BUY', total_price: 500000 }); + render(); + expect(screen.getByText(/500,000/)).toBeInTheDocument(); + expect(screen.queryByText('/mo')).not.toBeInTheDocument(); + }); + + it('renders bedrooms count', () => { + const property = createMockProperty({ rooms: 3 }); + render(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('renders size with m\u00B2', () => { + const property = createMockProperty({ qm: 65 }); + render(); + expect(screen.getByText(/65\s*m\u00B2/)).toBeInTheDocument(); + }); + + it('renders price per sqm', () => { + const property = createMockProperty({ qmprice: 38 }); + render(); + expect(screen.getByText(/38\/m\u00B2/)).toBeInTheDocument(); + }); + + it('renders agency name', () => { + const property = createMockProperty({ agency: 'Foxtons' }); + render(); + expect(screen.getByText('Foxtons')).toBeInTheDocument(); + }); + + it('renders photo thumbnail when present', () => { + const property = createMockProperty({ photo_thumbnail: 'https://example.com/img.jpg' }); + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'https://example.com/img.jpg'); + }); + + it('shows Good deal badge when qmprice is below 90% of average', () => { + const property = createMockProperty({ qmprice: 40 }); + render(); + expect(screen.getByText('Good deal')).toBeInTheDocument(); + }); + + it('shows Above avg badge when qmprice exceeds 110% of average', () => { + const property = createMockProperty({ qmprice: 40 }); + render(); + expect(screen.getByText('Above avg')).toBeInTheDocument(); + }); + + it('shows no badge when qmprice is near average', () => { + const property = createMockProperty({ qmprice: 38 }); + render(); + expect(screen.queryByText('Good deal')).not.toBeInTheDocument(); + expect(screen.queryByText('Above avg')).not.toBeInTheDocument(); + }); + + it('calls onClick and opens window on click', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + const property = createMockProperty({ url: 'https://rightmove.co.uk/123' }); + render(); + + await user.click(screen.getByText(/2,500/)); + expect(openSpy).toHaveBeenCalledWith('https://rightmove.co.uk/123', '_blank', 'noopener,noreferrer'); + expect(onClick).toHaveBeenCalled(); + + openSpy.mockRestore(); + }); + + it('applies ring-2 class when highlighted', () => { + const property = createMockProperty(); + const { container } = render(); + expect(container.querySelector('.ring-2')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/StreamingProgressBar.test.tsx b/frontend/src/components/__tests__/StreamingProgressBar.test.tsx new file mode 100644 index 0000000..f9d0b80 --- /dev/null +++ b/frontend/src/components/__tests__/StreamingProgressBar.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { StreamingProgressBar } from '@/components/StreamingProgressBar'; +import type { StreamingProgress } from '@/services'; + +describe('StreamingProgressBar', () => { + it('returns null when not loading', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('shows loading text when loading with no progress', () => { + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('shows count and total when both provided', () => { + const progress: StreamingProgress = { count: 25, total: 100 }; + render(); + expect(screen.getByText(/25/)).toBeInTheDocument(); + expect(screen.getByText(/100/)).toBeInTheDocument(); + }); + + it('shows count without total', () => { + const progress: StreamingProgress = { count: 25 }; + render(); + expect(screen.getByText(/25/)).toBeInTheDocument(); + expect(screen.getByText(/loaded/)).toBeInTheDocument(); + }); + + it('sets progress bar width based on count/total ratio', () => { + const progress: StreamingProgress = { count: 50, total: 100 }; + const { container } = render( + , + ); + const progressBar = container.querySelector('.bg-primary.transition-all'); + expect(progressBar).toHaveStyle({ width: '50%' }); + }); +}); diff --git a/frontend/src/components/__tests__/TaskIndicator.test.tsx b/frontend/src/components/__tests__/TaskIndicator.test.tsx new file mode 100644 index 0000000..a62c6eb --- /dev/null +++ b/frontend/src/components/__tests__/TaskIndicator.test.tsx @@ -0,0 +1,108 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TaskIndicator } from '@/components/TaskIndicator'; +import { createMockTaskState } from '@/__tests__/helpers'; +import type { TaskState } from '@/types'; + +const defaultProps = { + isConnected: true, + onCancelTask: vi.fn(async () => true), + onClearAllTasks: vi.fn(async () => true), +}; + +function renderIndicator(tasks: Record, activeTaskId: string | null, extra = {}) { + return render( + , + ); +} + +describe('TaskIndicator', () => { + it('returns null when no active task', () => { + const { container } = renderIndicator({}, null); + expect(container.firstChild).toBeNull(); + }); + + it('returns null when activeTaskId has no matching task', () => { + const { container } = renderIndicator({}, 'missing-id'); + expect(container.firstChild).toBeNull(); + }); + + it('shows spinner when task is running', () => { + const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) }; + const { container } = renderIndicator(tasks, 't1'); + // Loader2 renders an svg with animate-spin class + expect(container.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('shows check icon on success', () => { + const tasks = { 't1': createMockTaskState({ status: 'SUCCESS' }) }; + const { container } = renderIndicator(tasks, 't1'); + expect(container.querySelector('.text-green-500')).toBeInTheDocument(); + }); + + it('shows X icon on failure', () => { + const tasks = { 't1': createMockTaskState({ status: 'FAILURE' }) }; + const { container } = renderIndicator(tasks, 't1'); + expect(container.querySelector('.text-red-500')).toBeInTheDocument(); + }); + + it('shows progress text', () => { + const tasks = { + 't1': createMockTaskState({ status: 'STARTED', progress: 0.5, processed: 50, total: 100, phase: 'processing' }), + }; + renderIndicator(tasks, 't1'); + expect(screen.getByText('50 / 100')).toBeInTheDocument(); + }); + + it('opens drawer on click', async () => { + const user = userEvent.setup(); + const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) }; + renderIndicator(tasks, 't1'); + + // Click the task indicator area + const clickable = screen.getByText(/50/).closest('[class*="cursor-pointer"]'); + if (clickable) { + await user.click(clickable); + } + + // The drawer (Sheet) should now be open - SheetTitle renders "Job Progress" + await waitFor(() => { + expect(screen.getByText(/Job Progress/)).toBeInTheDocument(); + }); + }); + + it('shows task count badge when multiple active tasks', () => { + const tasks = { + 't1': createMockTaskState({ task_id: 't1', status: 'STARTED' }), + 't2': createMockTaskState({ task_id: 't2', status: 'STARTED' }), + }; + renderIndicator(tasks, 't1'); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('fires onTaskCompleted when active task transitions to SUCCESS', async () => { + const onTaskCompleted = vi.fn(); + const tasks = { 't1': createMockTaskState({ status: 'STARTED' }) }; + + const { rerender } = render( + , + ); + + const updatedTasks = { 't1': createMockTaskState({ status: 'SUCCESS' }) }; + rerender( + , + ); + + expect(onTaskCompleted).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/__tests__/TaskProgressDrawer.test.tsx b/frontend/src/components/__tests__/TaskProgressDrawer.test.tsx new file mode 100644 index 0000000..a7b2009 --- /dev/null +++ b/frontend/src/components/__tests__/TaskProgressDrawer.test.tsx @@ -0,0 +1,162 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TaskProgressDrawer } from '@/components/TaskProgressDrawer'; +import { createMockTaskState } from '@/__tests__/helpers'; +import { TaskStatus } from '@/types'; +import type { TaskResult, TaskState } from '@/types'; + +const baseProps = { + open: true, + onOpenChange: vi.fn(), + taskID: 'test-task-123', + onCancel: vi.fn(), + isCancelling: false, +}; + +function makeResult(overrides: Partial = {}): TaskResult { + return { + progress: 0.5, + processed: 50, + total: 100, + phase: 'fetching', + ...overrides, + }; +} + +describe('TaskProgressDrawer', () => { + it('renders phase timeline labels for scrape task', () => { + render( + , + ); + expect(screen.getByText('Splitting queries')).toBeInTheDocument(); + // "Fetching & processing" appears in both the timeline and phase details + expect(screen.getAllByText('Fetching & processing').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Processing remaining')).toBeInTheDocument(); + }); + + it('shows Running badge when task is in progress', () => { + render( + , + ); + expect(screen.getByText('Running')).toBeInTheDocument(); + }); + + it('shows Complete badge on success', () => { + render( + , + ); + expect(screen.getByText('Complete')).toBeInTheDocument(); + }); + + it('shows Cancelled badge when revoked', () => { + render( + , + ); + expect(screen.getByText('Cancelled')).toBeInTheDocument(); + }); + + it('shows Failed badge on failure', () => { + render( + , + ); + expect(screen.getByText('Failed')).toBeInTheDocument(); + }); + + it('shows processing metrics when in fetching phase', () => { + render( + , + ); + expect(screen.getByText('Details fetched')).toBeInTheDocument(); + expect(screen.getByText('Images downloaded')).toBeInTheDocument(); + expect(screen.getByText('OCR completed')).toBeInTheDocument(); + }); + + it('shows Cancel Job button when task is running', async () => { + const onCancel = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const cancelBtn = screen.getByText('Cancel Job'); + expect(cancelBtn).toBeInTheDocument(); + await user.click(cancelBtn); + expect(onCancel).toHaveBeenCalled(); + }); + + it('infers POI task type from computing phase', () => { + const tasks: Record = { + 'poi-1': createMockTaskState({ + task_id: 'poi-1', + status: 'STARTED', + phase: 'computing', + distances_computed: 10, + }), + }; + render( + , + ); + // Title should say "POI Distances Job Progress" + expect(screen.getByText('POI Distances Job Progress')).toBeInTheDocument(); + // POI phase details section + expect(screen.getByText('Computing distances')).toBeInTheDocument(); + }); + + it('shows ETA when eta_seconds is provided', () => { + render( + , + ); + expect(screen.getByText(/2m.*remaining/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/__tests__/useTaskProgress.test.ts b/frontend/src/hooks/__tests__/useTaskProgress.test.ts new file mode 100644 index 0000000..ea7841c --- /dev/null +++ b/frontend/src/hooks/__tests__/useTaskProgress.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useTaskProgress } from '@/hooks/useTaskProgress'; +import { mockUser, createMockTaskState } from '@/__tests__/helpers'; +import type { AuthUser } from '@/auth/types'; + +// Mock WebSocket +class MockWebSocket { + static instances: MockWebSocket[] = []; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onerror: ((e: unknown) => void) | null = null; + readyState = 0; + url: string; + + static readonly OPEN = 1; + static readonly CLOSED = 3; + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + send = vi.fn(); + close = vi.fn(); + + simulateOpen() { + this.readyState = 1; + this.onopen?.(); + } + + simulateMessage(data: object) { + this.onmessage?.({ data: JSON.stringify(data) }); + } + + simulateClose() { + this.readyState = 3; + this.onclose?.(); + } +} + +// Mock the services module to prevent actual API calls during polling +vi.mock('@/services', () => ({ + fetchTasksForUser: vi.fn().mockResolvedValue([]), + fetchTaskStatus: vi.fn().mockResolvedValue({ + task_id: 'test', + status: 'PENDING', + result: null, + progress: null, + processed: null, + total: null, + message: null, + error: null, + traceback: null, + }), + cancelTask: vi.fn().mockResolvedValue({ success: true, message: 'Cancelled' }), + clearAllTasks: vi.fn().mockResolvedValue({ success: true, count: 0, message: 'Cleared' }), +})); + +describe('useTaskProgress', () => { + let originalWebSocket: typeof WebSocket; + + beforeEach(() => { + vi.useFakeTimers(); + MockWebSocket.instances = []; + originalWebSocket = globalThis.WebSocket; + // @ts-expect-error mock WebSocket + globalThis.WebSocket = MockWebSocket; + }); + + afterEach(() => { + vi.useRealTimers(); + globalThis.WebSocket = originalWebSocket; + vi.restoreAllMocks(); + }); + + it('initializes with empty tasks when user is null', () => { + const { result } = renderHook(() => useTaskProgress(null)); + expect(result.current.tasks).toEqual({}); + }); + + it('initializes with isConnected false', () => { + const { result } = renderHook(() => useTaskProgress(null)); + expect(result.current.isConnected).toBe(false); + }); + + it('creates WebSocket when user is provided', () => { + renderHook(() => useTaskProgress(mockUser())); + expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(1); + expect(MockWebSocket.instances[0].url).toContain('token=test-access-token'); + }); + + it('populates tasks from WebSocket init message', async () => { + const { result } = renderHook(() => useTaskProgress(mockUser())); + + const ws = MockWebSocket.instances[0]; + act(() => { + ws.simulateOpen(); + }); + + const taskState = createMockTaskState({ task_id: 'task-1', status: 'STARTED' }); + act(() => { + ws.simulateMessage({ type: 'init', tasks: [taskState] }); + }); + + expect(result.current.tasks['task-1']).toBeDefined(); + expect(result.current.tasks['task-1'].status).toBe('STARTED'); + }); + + it('updates task state from WebSocket task_update message', async () => { + const { result } = renderHook(() => useTaskProgress(mockUser())); + + const ws = MockWebSocket.instances[0]; + act(() => { + ws.simulateOpen(); + ws.simulateMessage({ + type: 'init', + tasks: [createMockTaskState({ task_id: 'task-1', status: 'PENDING' })], + }); + }); + + act(() => { + ws.simulateMessage({ + type: 'task_update', + task_id: 'task-1', + status: 'STARTED', + progress: 0.75, + }); + }); + + expect(result.current.tasks['task-1'].status).toBe('STARTED'); + }); + + it('sends subscribe message via WebSocket', async () => { + const { result } = renderHook(() => useTaskProgress(mockUser())); + + // The first MockWebSocket instance is created by connect() + const ws = MockWebSocket.instances[0]; + + await act(async () => { + ws.simulateOpen(); + // Let microtasks from startPolling/fetchAndPoll settle + await vi.advanceTimersByTimeAsync(0); + }); + + // Clear any send calls from onopen (pending subscriptions, keepalive) + ws.send.mockClear(); + + // Queue the subscription as pending, then simulate a fresh WS open to flush it + // Actually, let's test the pending subscription path instead: + // If WS isn't OPEN, subscribe queues the task ID. + // When WS opens, pending subscriptions are sent. + const ws2result = renderHook(() => useTaskProgress(mockUser())); + const ws2 = MockWebSocket.instances[MockWebSocket.instances.length - 1]; + + // Subscribe before WS is open (readyState = 0) + act(() => { + ws2result.result.current.subscribe('task-pending'); + }); + + // Now open the WS — pending subscriptions should be flushed + await act(async () => { + ws2.simulateOpen(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(ws2.send).toHaveBeenCalledWith( + JSON.stringify({ type: 'subscribe', task_id: 'task-pending' }), + ); + }); + + it('closes WebSocket on unmount', () => { + const { unmount } = renderHook(() => useTaskProgress(mockUser())); + + const ws = MockWebSocket.instances[0]; + act(() => { + ws.simulateOpen(); + }); + + unmount(); + expect(ws.close).toHaveBeenCalled(); + }); + + it('triggers polling that calls fetch for task status', async () => { + const { fetchTasksForUser } = await import('@/services'); + + renderHook(() => useTaskProgress(mockUser())); + + // The hook starts polling immediately — advance past the first poll interval + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + // fetchTasksForUser should have been called at least once (initial poll) + expect(fetchTasksForUser).toHaveBeenCalled(); + }); + + it('calls cancelTask service', async () => { + const { cancelTask: mockCancel } = await import('@/services'); + + const { result } = renderHook(() => useTaskProgress(mockUser())); + + await act(async () => { + await result.current.cancelTask('task-1'); + }); + + expect(mockCancel).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'test-access-token' }), + 'task-1', + ); + }); + + it('calls clearAllTasks service', async () => { + const { clearAllTasks: mockClearAll } = await import('@/services'); + + const { result } = renderHook(() => useTaskProgress(mockUser())); + + await act(async () => { + await result.current.clearAllTasks(); + }); + + expect(mockClearAll).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: 'test-access-token' }), + ); + }); +}); diff --git a/frontend/src/services/__tests__/apiClient.test.ts b/frontend/src/services/__tests__/apiClient.test.ts new file mode 100644 index 0000000..629f980 --- /dev/null +++ b/frontend/src/services/__tests__/apiClient.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; +import { server } from '@/__tests__/mocks/server'; +import { http, HttpResponse } from 'msw'; +import { mockUser } from '@/__tests__/helpers'; +import { apiRequest } from '@/services/apiClient'; +import { ApiError } from '@/types'; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('apiClient', () => { + it('makes GET requests and returns JSON', async () => { + server.use( + http.get('/api/status', () => { + return HttpResponse.json({ status: 'OK' }); + }), + ); + + const result = await apiRequest<{ status: string }>(mockUser(), '/api/status'); + expect(result).toEqual({ status: 'OK' }); + }); + + it('includes Authorization header', async () => { + let receivedAuth: string | null = null; + + server.use( + http.get('/api/test-auth', ({ request }) => { + receivedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ ok: true }); + }), + ); + + const user = mockUser({ accessToken: 'my-secret-token' }); + await apiRequest(user, '/api/test-auth'); + + expect(receivedAuth).toBe('Bearer my-secret-token'); + }); + + it('builds URL with query params', async () => { + let requestUrl = ''; + + server.use( + http.get('/api/search', ({ request }) => { + requestUrl = request.url; + return HttpResponse.json({ results: [] }); + }), + ); + + await apiRequest(mockUser(), '/api/search', { + params: { limit: 5, active: true }, + }); + + const url = new URL(requestUrl); + expect(url.searchParams.get('limit')).toBe('5'); + expect(url.searchParams.get('active')).toBe('true'); + }); + + it('sends POST requests with JSON body', async () => { + let receivedMethod = ''; + let receivedBody: unknown = null; + + server.use( + http.post('/api/create', async ({ request }) => { + receivedMethod = request.method; + receivedBody = await request.json(); + return HttpResponse.json({ id: 1 }); + }), + ); + + const result = await apiRequest(mockUser(), '/api/create', { + method: 'POST', + body: { key: 'value' }, + }); + + expect(receivedMethod).toBe('POST'); + expect(receivedBody).toEqual({ key: 'value' }); + expect(result).toEqual({ id: 1 }); + }); + + it('throws ApiError on 404', async () => { + server.use( + http.get('/api/missing', () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + await expect(apiRequest(mockUser(), '/api/missing')).rejects.toThrow(ApiError); + await expect(apiRequest(mockUser(), '/api/missing')).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it('throws ApiError on 500', async () => { + server.use( + http.get('/api/error', () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + await expect(apiRequest(mockUser(), '/api/error')).rejects.toThrow(ApiError); + await expect(apiRequest(mockUser(), '/api/error')).rejects.toMatchObject({ + statusCode: 500, + }); + }); + + it('excludes undefined params from URL', async () => { + let requestUrl = ''; + + server.use( + http.get('/api/filter', ({ request }) => { + requestUrl = request.url; + return HttpResponse.json({}); + }), + ); + + await apiRequest(mockUser(), '/api/filter', { + params: { limit: 10, offset: undefined, name: 'test' }, + }); + + const url = new URL(requestUrl); + expect(url.searchParams.get('limit')).toBe('10'); + expect(url.searchParams.get('name')).toBe('test'); + expect(url.searchParams.has('offset')).toBe(false); + }); + + it('serializes Date params as ISO strings', async () => { + let requestUrl = ''; + + server.use( + http.get('/api/dated', ({ request }) => { + requestUrl = request.url; + return HttpResponse.json({}); + }), + ); + + const date = new Date('2024-06-15T12:00:00.000Z'); + await apiRequest(mockUser(), '/api/dated', { + params: { since: date }, + }); + + const url = new URL(requestUrl); + expect(url.searchParams.get('since')).toBe('2024-06-15T12:00:00.000Z'); + }); +}); diff --git a/frontend/src/services/__tests__/healthService.test.ts b/frontend/src/services/__tests__/healthService.test.ts new file mode 100644 index 0000000..03a11a1 --- /dev/null +++ b/frontend/src/services/__tests__/healthService.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { checkBackendHealth } from '@/services/healthService'; + +// In jsdom, AbortSignal.timeout() may not exist or may produce signals that +// internal fetch rejects. We mock fetch directly for all health service tests. +describe('healthService', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns healthy when backend responds with OK', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: 'OK' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await checkBackendHealth(); + expect(result.status).toBe('healthy'); + expect(result.error).toBeUndefined(); + }); + + it('returns unhealthy on non-ok HTTP response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(null, { status: 500 }), + ); + + const result = await checkBackendHealth(); + expect(result.status).toBe('unhealthy'); + expect(result.error).toBe('HTTP 500'); + }); + + it('returns unhealthy with unexpected response body', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: 'FAIL' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await checkBackendHealth(); + expect(result.status).toBe('unhealthy'); + expect(result.error).toBe('Unexpected response'); + }); + + it('measures latency as a non-negative number', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ status: 'OK' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const result = await checkBackendHealth(); + expect(result.latencyMs).toBeDefined(); + expect(typeof result.latencyMs).toBe('number'); + expect(result.latencyMs!).toBeGreaterThanOrEqual(0); + }); + + it('returns unhealthy on network error', async () => { + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + + const result = await checkBackendHealth(); + expect(result.status).toBe('unhealthy'); + expect(result.error).toBe('Failed to fetch'); + expect(result.latencyMs).toBeDefined(); + }); + + it('handles AbortError as timeout', async () => { + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + globalThis.fetch = vi.fn().mockRejectedValue(abortError); + + const result = await checkBackendHealth(); + expect(result.status).toBe('unhealthy'); + expect(result.error).toBe('Request timeout'); + }); +}); diff --git a/frontend/src/services/__tests__/listingService.test.ts b/frontend/src/services/__tests__/listingService.test.ts new file mode 100644 index 0000000..c3bee07 --- /dev/null +++ b/frontend/src/services/__tests__/listingService.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; +import { server } from '@/__tests__/mocks/server'; +import { http, HttpResponse } from 'msw'; +import { mockUser, createMockFeatureCollection } from '@/__tests__/helpers'; +import { fetchListingGeoJSON, refreshListings } from '@/services/listingService'; +import type { ParameterValues } from '@/components/FilterPanel'; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const defaultParams: ParameterValues = { + metric: 'qmprice', + listing_type: 'RENT', + min_bedrooms: 1, + max_bedrooms: 3, + min_price: 1000, + max_price: 5000, + district: '', +}; + +describe('listingService', () => { + it('fetchListingGeoJSON returns a FeatureCollection', async () => { + const collection = createMockFeatureCollection(3); + + server.use( + http.get('/api/listing_geojson', () => { + return HttpResponse.json(collection); + }), + ); + + const result = await fetchListingGeoJSON(mockUser(), defaultParams); + expect(result.type).toBe('FeatureCollection'); + expect(result.features).toHaveLength(3); + }); + + it('fetchListingGeoJSON sends query parameters', async () => { + let requestUrl = ''; + + server.use( + http.get('/api/listing_geojson', ({ request }) => { + requestUrl = request.url; + return HttpResponse.json({ type: 'FeatureCollection', features: [] }); + }), + ); + + await fetchListingGeoJSON(mockUser(), defaultParams); + + const url = new URL(requestUrl); + expect(url.searchParams.get('listing_type')).toBe('RENT'); + expect(url.searchParams.get('min_bedrooms')).toBe('1'); + expect(url.searchParams.get('max_bedrooms')).toBe('3'); + expect(url.searchParams.get('min_price')).toBe('1000'); + expect(url.searchParams.get('max_price')).toBe('5000'); + }); + + it('refreshListings returns task_id', async () => { + server.use( + http.post('/api/refresh_listings', () => { + return HttpResponse.json({ task_id: 'abc-123' }); + }), + ); + + const result = await refreshListings(mockUser(), defaultParams); + expect(result.task_id).toBe('abc-123'); + }); + + it('refreshListings sends query parameters', async () => { + let requestUrl = ''; + + server.use( + http.post('/api/refresh_listings', ({ request }) => { + requestUrl = request.url; + return HttpResponse.json({ task_id: 'xyz' }); + }), + ); + + await refreshListings(mockUser(), defaultParams); + + const url = new URL(requestUrl); + expect(url.searchParams.get('listing_type')).toBe('RENT'); + expect(url.searchParams.get('min_bedrooms')).toBe('1'); + expect(url.searchParams.get('min_price')).toBe('1000'); + }); +}); diff --git a/frontend/src/services/__tests__/streamingService.test.ts b/frontend/src/services/__tests__/streamingService.test.ts new file mode 100644 index 0000000..90228ea --- /dev/null +++ b/frontend/src/services/__tests__/streamingService.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mockUser, createMockFeature } from '@/__tests__/helpers'; +import { streamListingGeoJSON } from '@/services/streamingService'; +import { ApiError } from '@/types'; +import type { ParameterValues } from '@/components/FilterPanel'; + +const defaultParams: ParameterValues = { + metric: 'qmprice', + listing_type: 'RENT', + min_bedrooms: 1, + max_bedrooms: 3, + min_price: 1000, + max_price: 5000, + district: '', +}; + +function createMockResponse(lines: string[]) { + const body = lines.join('\n') + '\n'; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { 'Content-Type': 'application/x-ndjson' }, + }); +} + +describe('streamingService', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('yields feature batches', async () => { + const features = [createMockFeature(), createMockFeature({ rooms: 3 })]; + + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse([ + JSON.stringify({ type: 'metadata', total_expected: 2 }), + JSON.stringify({ type: 'batch', features }), + JSON.stringify({ type: 'complete', total: 2 }), + ]), + ); + + const batches: unknown[][] = []; + for await (const batch of streamListingGeoJSON(mockUser(), defaultParams)) { + batches.push(batch); + } + + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(2); + }); + + it('calls onProgress with metadata', async () => { + const onProgress = vi.fn(); + + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse([ + JSON.stringify({ type: 'metadata', total_expected: 42 }), + JSON.stringify({ type: 'complete', total: 0 }), + ]), + ); + + const gen = streamListingGeoJSON(mockUser(), defaultParams, onProgress); + // Consume the generator + for await (const _ of gen) { /* drain */ } + + expect(onProgress).toHaveBeenCalledWith({ count: 0, total: 42 }); + }); + + it('calls onProgress with incrementing count on batches', async () => { + const features1 = [createMockFeature()]; + const features2 = [createMockFeature(), createMockFeature()]; + const onProgress = vi.fn(); + + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse([ + JSON.stringify({ type: 'metadata', total_expected: 3 }), + JSON.stringify({ type: 'batch', features: features1 }), + JSON.stringify({ type: 'batch', features: features2 }), + JSON.stringify({ type: 'complete', total: 3 }), + ]), + ); + + for await (const _ of streamListingGeoJSON(mockUser(), defaultParams, onProgress)) { /* drain */ } + + // metadata: count=0, batch1: count=1, batch2: count=3, complete: count=3 + expect(onProgress).toHaveBeenCalledWith({ count: 1 }); + expect(onProgress).toHaveBeenCalledWith({ count: 3 }); + }); + + it('calls onProgress on complete message', async () => { + const onProgress = vi.fn(); + + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse([ + JSON.stringify({ type: 'metadata', total_expected: 5 }), + JSON.stringify({ type: 'complete', total: 5 }), + ]), + ); + + for await (const _ of streamListingGeoJSON(mockUser(), defaultParams, onProgress)) { /* drain */ } + + expect(onProgress).toHaveBeenCalledWith({ count: 5, total: 5 }); + }); + + it('handles multiple batches and yields each separately', async () => { + const batch1 = [createMockFeature({ rooms: 1 })]; + const batch2 = [createMockFeature({ rooms: 2 })]; + + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse([ + JSON.stringify({ type: 'metadata', total_expected: 2 }), + JSON.stringify({ type: 'batch', features: batch1 }), + JSON.stringify({ type: 'batch', features: batch2 }), + JSON.stringify({ type: 'complete', total: 2 }), + ]), + ); + + const batches: unknown[][] = []; + for await (const batch of streamListingGeoJSON(mockUser(), defaultParams)) { + batches.push(batch); + } + + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(1); + expect(batches[1]).toHaveLength(1); + }); + + it('handles abort signal', async () => { + const controller = new AbortController(); + controller.abort(); + + globalThis.fetch = vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')); + + const batches: unknown[][] = []; + try { + for await (const batch of streamListingGeoJSON(mockUser(), defaultParams, undefined, { signal: controller.signal })) { + batches.push(batch); + } + } catch { + // Expected: fetch throws on aborted signal + } + + expect(batches).toHaveLength(0); + }); + + it('handles malformed JSON lines without crashing', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const features = [createMockFeature()]; + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse([ + JSON.stringify({ type: 'metadata', total_expected: 1 }), + 'this is not valid json{{{', + JSON.stringify({ type: 'batch', features }), + JSON.stringify({ type: 'complete', total: 1 }), + ]), + ); + + const batches: unknown[][] = []; + for await (const batch of streamListingGeoJSON(mockUser(), defaultParams)) { + batches.push(batch); + } + + // Should still yield the valid batch + expect(batches).toHaveLength(1); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('handles empty stream with no batches', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + createMockResponse([ + JSON.stringify({ type: 'metadata', total_expected: 0 }), + JSON.stringify({ type: 'complete', total: 0 }), + ]), + ); + + const batches: unknown[][] = []; + for await (const batch of streamListingGeoJSON(mockUser(), defaultParams)) { + batches.push(batch); + } + + expect(batches).toHaveLength(0); + }); + + it('throws ApiError on HTTP error response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(null, { status: 401 }), + ); + + await expect(async () => { + for await (const _ of streamListingGeoJSON(mockUser(), defaultParams)) { /* drain */ } + }).rejects.toThrow(ApiError); + }); + + it('throws when response body is null', async () => { + const response = new Response(null, { status: 200 }); + // Force body to null by creating a response without a body that reports ok + Object.defineProperty(response, 'body', { value: null }); + + globalThis.fetch = vi.fn().mockResolvedValue(response); + + await expect(async () => { + for await (const _ of streamListingGeoJSON(mockUser(), defaultParams)) { /* drain */ } + }).rejects.toThrow('No response body'); + }); +}); diff --git a/frontend/src/services/__tests__/taskService.test.ts b/frontend/src/services/__tests__/taskService.test.ts new file mode 100644 index 0000000..cc4e6ef --- /dev/null +++ b/frontend/src/services/__tests__/taskService.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeAll, afterEach, afterAll } from 'vitest'; +import { server } from '@/__tests__/mocks/server'; +import { http, HttpResponse } from 'msw'; +import { mockUser } from '@/__tests__/helpers'; +import { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks } from '@/services/taskService'; +import { ApiError } from '@/types'; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('taskService', () => { + it('fetchTasksForUser returns task IDs', async () => { + server.use( + http.get('/api/tasks_for_user', () => { + return HttpResponse.json(['task-1', 'task-2']); + }), + ); + + const result = await fetchTasksForUser(mockUser()); + expect(result).toEqual(['task-1', 'task-2']); + }); + + it('fetchTaskStatus returns task status response', async () => { + server.use( + http.get('/api/task_status', () => { + return HttpResponse.json({ + task_id: 'task-42', + status: 'STARTED', + result: null, + progress: 0.5, + processed: 25, + total: 50, + message: 'Processing...', + error: null, + traceback: null, + }); + }), + ); + + const result = await fetchTaskStatus(mockUser(), 'task-42'); + expect(result.task_id).toBe('task-42'); + expect(result.status).toBe('STARTED'); + expect(result.progress).toBe(0.5); + expect(result.processed).toBe(25); + expect(result.total).toBe(50); + expect(result.message).toBe('Processing...'); + }); + + it('cancelTask sends POST and returns success', async () => { + let requestUrl = ''; + + server.use( + http.post('/api/cancel_task', ({ request }) => { + requestUrl = request.url; + return HttpResponse.json({ success: true, message: 'Task cancelled' }); + }), + ); + + const result = await cancelTask(mockUser(), 'task-99'); + expect(result.success).toBe(true); + expect(result.message).toBe('Task cancelled'); + expect(requestUrl).toContain('task_id=task-99'); + }); + + it('clearAllTasks sends POST and returns count', async () => { + server.use( + http.post('/api/clear_all_tasks', () => { + return HttpResponse.json({ success: true, count: 5, message: 'Cleared 5 tasks' }); + }), + ); + + const result = await clearAllTasks(mockUser()); + expect(result.success).toBe(true); + expect(result.count).toBe(5); + }); + + it('fetchTaskStatus throws ApiError on server error', async () => { + server.use( + http.get('/api/task_status', () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + await expect(fetchTaskStatus(mockUser(), 'task-fail')).rejects.toThrow(ApiError); + await expect(fetchTaskStatus(mockUser(), 'task-fail')).rejects.toMatchObject({ + statusCode: 500, + }); + }); +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..40e02c6 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react-swc'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/__tests__/setup.ts'], + css: false, + include: ['src/**/*.test.{ts,tsx}'], + }, +}); diff --git a/pyproject.toml b/pyproject.toml index 23b14ad..85c7aeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ pytest-cov = "^4.1.0" httpx = "^0.27.0" aioresponses = "^0.7.6" fakeredis = "^2.21.0" +pytest-xdist = "^3.5.0" mypy = "^1.8.0" types-requests = "^2.31.0" types-redis = "^4.6.0" @@ -65,6 +66,11 @@ exclude = ["*.ipynb"] asyncio_mode = "auto" testpaths = ["tests"] asyncio_default_fixture_loop_scope = "function" +markers = [ + "regression: locks down existing behavior", + "integration: tests multiple components together", + "e2e: end-to-end workflow tests", +] [tool.mypy] python_version = "3.11" diff --git a/tests/conftest.py b/tests/conftest.py index 012e6e3..141488c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,8 @@ """Shared pytest fixtures for the test suite.""" from datetime import datetime -from typing import AsyncGenerator, Generator +from typing import Any, AsyncGenerator, Callable, Generator import pytest +import fakeredis from sqlalchemy import Engine from sqlmodel import SQLModel, Session, create_engine from httpx import ASGITransport, AsyncClient @@ -184,3 +185,59 @@ async def async_client( # Clean up dependency overrides app.dependency_overrides.clear() + + +@pytest.fixture +def fake_redis() -> Generator[fakeredis.FakeRedis, None, None]: + """Create a fakeredis client, flushed after each test.""" + client = fakeredis.FakeRedis(decode_responses=True) + yield client + client.flushall() + + +@pytest.fixture +def rent_listing_factory() -> Callable[..., RentListing]: + """Factory function that creates RentListing with overridable defaults.""" + _counter = 0 + + def _create(**overrides: Any) -> RentListing: + nonlocal _counter + _counter += 1 + defaults: dict[str, Any] = dict( + id=_counter, + price=2000.0, + number_of_bedrooms=2, + square_meters=55.0, + agency="Test Agency", + council_tax_band="C", + longitude=-0.1276, + latitude=51.5074, + price_history_json="[]", + listing_site=ListingSite.RIGHTMOVE, + last_seen=datetime.now(), + photo_thumbnail="https://example.com/photo.jpg", + floorplan_image_paths=[], + additional_info={"property": {"visible": True}}, + routing_info_json=None, + furnish_type=FurnishType.FURNISHED, + available_from=datetime.now(), + ) + defaults.update(overrides) + return RentListing(**defaults) + + return _create + + +@pytest.fixture +async def seeded_repository( + in_memory_engine: Engine, + rent_listing_factory: Callable[..., RentListing], +) -> ListingRepository: + """Repository with 10 pre-seeded listings (varied price/bedrooms/sqm).""" + repo = ListingRepository(engine=in_memory_engine) + listings = [ + rent_listing_factory(id=100 + i, price=1000 + i * 300, number_of_bedrooms=(i % 4) + 1, square_meters=30 + i * 10) + for i in range(10) + ] + await repo.upsert_listings(listings) + return repo diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/test_full_workflows.py b/tests/e2e/test_full_workflows.py new file mode 100644 index 0000000..867eb69 --- /dev/null +++ b/tests/e2e/test_full_workflows.py @@ -0,0 +1,203 @@ +"""End-to-end tests for full API workflows.""" +import json +from unittest.mock import AsyncMock + +import pytest +from httpx import AsyncClient +from sqlalchemy import Engine + +from repositories.listing_repository import ListingRepository + + +pytestmark = pytest.mark.e2e + + +@pytest.fixture(autouse=True) +def patch_db_engine(in_memory_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None: + import database + import api.app + import api.rate_limiter + monkeypatch.setattr(database, "engine", in_memory_engine) + monkeypatch.setattr(api.app, "engine", in_memory_engine) + # Disable rate limiting for E2E tests + monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None) + + +@pytest.fixture(autouse=True) +def patch_redis_client(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis) + + +def _parse_ndjson(text: str) -> list[dict]: + return [json.loads(line) for line in text.strip().split("\n") if line.strip()] + + +# ---------- Streaming with filters ---------- + + +@pytest.mark.asyncio +async def test_seed_and_stream_with_filter( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [ + rent_listing_factory(id=i, price=1000 + i * 300, square_meters=40.0) + for i in range(1, 21) + ] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get( + "/api/listing_geojson/stream?listing_type=RENT&max_price=3000" + ) + lines = _parse_ndjson(resp.text) + batches = [l for l in lines if l["type"] == "batch"] + all_features = [f for b in batches for f in b["features"]] + complete = lines[-1] + + for feat in all_features: + assert feat["properties"]["total_price"] <= 3000 + assert complete["type"] == "complete" + assert complete["total"] == len(all_features) + + +@pytest.mark.asyncio +async def test_large_batch_streaming( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [ + rent_listing_factory(id=i, square_meters=40.0) + for i in range(1, 201) + ] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get( + "/api/listing_geojson/stream?listing_type=RENT&batch_size=50" + ) + lines = _parse_ndjson(resp.text) + batches = [l for l in lines if l["type"] == "batch"] + complete = lines[-1] + + assert len(batches) == 4 + assert complete["total"] == 200 + + +@pytest.mark.asyncio +async def test_empty_result_set(async_client: AsyncClient) -> None: + resp = await async_client.get( + "/api/listing_geojson/stream?listing_type=RENT" + ) + lines = _parse_ndjson(resp.text) + + assert lines[0]["type"] == "metadata" + complete = lines[-1] + assert complete["type"] == "complete" + assert complete["total"] == 0 + + +@pytest.mark.asyncio +async def test_refresh_creates_task( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from services.listing_service import RefreshResult + + async def fake_refresh(*args, **kwargs): + return RefreshResult(task_id="e2e-task-1", new_listings_count=0, message="started") + + monkeypatch.setattr("services.listing_service.refresh_listings", fake_refresh) + monkeypatch.setattr("services.task_service.add_task_for_user", lambda email, tid: None) + monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None)) + + resp = await async_client.post("/api/refresh_listings?listing_type=RENT") + assert resp.status_code == 200 + assert resp.json()["task_id"] == "e2e-task-1" + + +@pytest.mark.asyncio +async def test_cache_populated_on_first_stream( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 6)] + await listing_repository.upsert_listings(listings) + + # First stream — cache miss, populated from DB + resp1 = await async_client.get("/api/listing_geojson/stream?listing_type=RENT") + lines1 = _parse_ndjson(resp1.text) + assert lines1[0]["cached"] is False + + # Second stream — should hit cache + resp2 = await async_client.get("/api/listing_geojson/stream?listing_type=RENT") + lines2 = _parse_ndjson(resp2.text) + assert lines2[0]["cached"] is True + + +@pytest.mark.asyncio +async def test_stream_filter_price( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [ + rent_listing_factory(id=i, price=500 * i, square_meters=40.0) + for i in range(1, 11) + ] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get( + "/api/listing_geojson/stream?listing_type=RENT&min_price=1500&max_price=3000" + ) + lines = _parse_ndjson(resp.text) + batches = [l for l in lines if l["type"] == "batch"] + all_features = [f for b in batches for f in b["features"]] + + for feat in all_features: + assert 1500 <= feat["properties"]["total_price"] <= 3000 + + +@pytest.mark.asyncio +async def test_stream_filter_bedrooms( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [ + rent_listing_factory(id=i, number_of_bedrooms=(i % 4) + 1, square_meters=40.0) + for i in range(1, 21) + ] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get( + "/api/listing_geojson/stream?listing_type=RENT&min_bedrooms=2&max_bedrooms=3" + ) + lines = _parse_ndjson(resp.text) + batches = [l for l in lines if l["type"] == "batch"] + all_features = [f for b in batches for f in b["features"]] + + for feat in all_features: + assert 2 <= feat["properties"]["rooms"] <= 3 + assert len(all_features) > 0 + + +@pytest.mark.asyncio +async def test_complete_total_matches_actual( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 16)] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT") + lines = _parse_ndjson(resp.text) + batches = [l for l in lines if l["type"] == "batch"] + total_features = sum(len(b["features"]) for b in batches) + complete = lines[-1] + + assert complete["type"] == "complete" + assert complete["total"] == total_features + assert complete["total"] == 15 diff --git a/tests/integration/test_api_workflow.py b/tests/integration/test_api_workflow.py new file mode 100644 index 0000000..132fc4f --- /dev/null +++ b/tests/integration/test_api_workflow.py @@ -0,0 +1,320 @@ +"""Integration tests for API workflow endpoints.""" +import json +from unittest.mock import AsyncMock + +import pytest +from httpx import AsyncClient +from sqlalchemy import Engine + +from repositories.listing_repository import ListingRepository +from services.task_service import TaskStatus + + +@pytest.fixture(autouse=True) +def patch_db_engine(in_memory_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None: + """Patch the database engine and disable rate limiting for tests.""" + import database + import api.app + import api.rate_limiter + monkeypatch.setattr(database, "engine", in_memory_engine) + monkeypatch.setattr(api.app, "engine", in_memory_engine) + # Disable rate limiting by making _match_endpoint always return None + monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None) + + +@pytest.fixture(autouse=True) +def patch_redis_for_streaming(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None: + """Patch Redis client used by listing cache so streaming doesn't hit real Redis.""" + monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis) + + +# ---------- Listing query tests ---------- + + +@pytest.mark.asyncio +async def test_listing_returns_inserted_data( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [rent_listing_factory(id=i) for i in range(1, 4)] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get("/api/listing?limit=10") + assert resp.status_code == 200 + data = resp.json() + assert len(data["listings"]) == 3 + + +@pytest.mark.asyncio +async def test_listing_respects_limit( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [rent_listing_factory(id=i) for i in range(1, 6)] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get("/api/listing?limit=2") + assert resp.status_code == 200 + assert len(resp.json()["listings"]) == 2 + + +@pytest.mark.asyncio +async def test_geojson_returns_feature_collection( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [rent_listing_factory(id=i, square_meters=50.0) for i in range(1, 4)] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get("/api/listing_geojson?listing_type=RENT") + assert resp.status_code == 200 + data = resp.json() + assert data["type"] == "FeatureCollection" + assert len(data["features"]) == 3 + + +@pytest.mark.asyncio +async def test_geojson_features_have_properties( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listing = rent_listing_factory(id=1, price=2500, number_of_bedrooms=2, square_meters=60.0) + await listing_repository.upsert_listings([listing]) + + resp = await async_client.get("/api/listing_geojson?listing_type=RENT") + assert resp.status_code == 200 + features = resp.json()["features"] + assert len(features) == 1 + + feat = features[0] + assert feat["geometry"]["type"] == "Point" + props = feat["properties"] + for key in ("url", "total_price", "rooms", "qm", "qmprice", "agency", "last_seen"): + assert key in props, f"Missing property: {key}" + + +# ---------- Streaming tests ---------- + + +def _parse_ndjson(text: str) -> list[dict]: + return [json.loads(line) for line in text.strip().split("\n") if line.strip()] + + +@pytest.mark.asyncio +async def test_stream_metadata_batch_complete( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 4)] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT") + assert resp.status_code == 200 + lines = _parse_ndjson(resp.text) + + assert lines[0]["type"] == "metadata" + batches = [l for l in lines if l["type"] == "batch"] + assert len(batches) >= 1 + complete = lines[-1] + assert complete["type"] == "complete" + assert complete["total"] == 3 + + +@pytest.mark.asyncio +async def test_stream_respects_limit( + async_client: AsyncClient, + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listings = [rent_listing_factory(id=i, square_meters=40.0) for i in range(1, 11)] + await listing_repository.upsert_listings(listings) + + resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT&limit=3") + lines = _parse_ndjson(resp.text) + complete = lines[-1] + assert complete["type"] == "complete" + assert complete["total"] == 3 + + +@pytest.mark.asyncio +async def test_stream_empty_db(async_client: AsyncClient) -> None: + resp = await async_client.get("/api/listing_geojson/stream?listing_type=RENT") + lines = _parse_ndjson(resp.text) + + assert lines[0]["type"] == "metadata" + complete = lines[-1] + assert complete["type"] == "complete" + assert complete["total"] == 0 + + +# ---------- Task management tests ---------- + + +@pytest.mark.asyncio +async def test_refresh_returns_task_id( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from services.listing_service import RefreshResult + async def fake_refresh(*args, **kwargs): + return RefreshResult(task_id="test-123", new_listings_count=0, message="Task started") + + import services.listing_service + monkeypatch.setattr(services.listing_service, "refresh_listings", fake_refresh) + monkeypatch.setattr("services.task_service.add_task_for_user", lambda email, tid: None) + monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None)) + + resp = await async_client.post("/api/refresh_listings?listing_type=RENT") + assert resp.status_code == 200 + data = resp.json() + assert data["task_id"] == "test-123" + + +@pytest.mark.asyncio +async def test_task_tracked_for_user( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from services.listing_service import RefreshResult + async def fake_refresh(*args, **kwargs): + return RefreshResult(task_id="task-abc", new_listings_count=0, message="ok") + + import services.listing_service + monkeypatch.setattr(services.listing_service, "refresh_listings", fake_refresh) + monkeypatch.setattr("notifications.send_notification", AsyncMock(return_value=None)) + + calls: list[tuple[str, str]] = [] + import services.task_service + monkeypatch.setattr( + services.task_service, + "add_task_for_user", + lambda email, tid: calls.append((email, tid)), + ) + + await async_client.post("/api/refresh_listings?listing_type=RENT") + assert len(calls) == 1 + assert calls[0] == ("test@example.com", "task-abc") + + +@pytest.mark.asyncio +async def test_task_status_works( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["test-123"]) + monkeypatch.setattr( + "services.task_service.get_task_status", + lambda task_id: TaskStatus( + task_id="test-123", + status="SUCCESS", + result=None, + progress=1.0, + processed=10, + total=10, + message="Done", + error=None, + traceback=None, + ), + ) + + resp = await async_client.get("/api/task_status?task_id=test-123") + assert resp.status_code == 200 + data = resp.json() + assert data["task_id"] == "test-123" + assert data["status"] == "SUCCESS" + assert data["progress"] == 1.0 + + +@pytest.mark.asyncio +async def test_task_not_found_returns_404( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: []) + + resp = await async_client.get("/api/task_status?task_id=unknown") + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_cancel_task( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["test-123"]) + monkeypatch.setattr("services.task_service.cancel_task", lambda task_id, user_email=None: True) + + resp = await async_client.post("/api/cancel_task?task_id=test-123") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + + +@pytest.mark.asyncio +async def test_clear_all_tasks( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("services.task_service.clear_all_tasks", lambda email: 3) + + resp = await async_client.post("/api/clear_all_tasks") + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 3 + assert data["success"] is True + + +# ---------- Additional edge cases ---------- + + +@pytest.mark.asyncio +async def test_listing_empty_db_returns_empty(async_client: AsyncClient) -> None: + resp = await async_client.get("/api/listing?limit=10") + assert resp.status_code == 200 + assert resp.json()["listings"] == [] + + +@pytest.mark.asyncio +async def test_geojson_empty_db_returns_empty_collection(async_client: AsyncClient) -> None: + resp = await async_client.get("/api/listing_geojson?listing_type=RENT") + assert resp.status_code == 200 + data = resp.json() + assert data["type"] == "FeatureCollection" + assert len(data["features"]) == 0 + + +@pytest.mark.asyncio +async def test_cancel_task_not_owned( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: []) + + resp = await async_client.post("/api/cancel_task?task_id=not-mine") + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is False + + +@pytest.mark.asyncio +async def test_tasks_for_user( + async_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("services.task_service.get_user_tasks", lambda email: ["a", "b", "c"]) + + resp = await async_client.get("/api/tasks_for_user") + assert resp.status_code == 200 + assert resp.json() == ["a", "b", "c"] + + +@pytest.mark.asyncio +async def test_status_endpoint(async_client: AsyncClient) -> None: + resp = await async_client.get("/api/status") + assert resp.status_code == 200 + assert resp.json()["status"] == "OK" diff --git a/tests/integration/test_listing_cache.py b/tests/integration/test_listing_cache.py new file mode 100644 index 0000000..808c8d9 --- /dev/null +++ b/tests/integration/test_listing_cache.py @@ -0,0 +1,132 @@ +"""Integration tests for Redis-based listing cache.""" +import pytest + +from models.listing import ListingType, QueryParameters +from services.listing_cache import ( + begin_cache_population, + cache_features_batch, + cache_features_batch_staged, + delete_staging_key, + finalize_cache_population, + get_cached_count, + get_cached_features, + invalidate_cache, + make_cache_key, +) + + +@pytest.fixture(autouse=True) +def patch_redis(fake_redis, monkeypatch: pytest.MonkeyPatch) -> None: + """Route all cache operations through fakeredis.""" + monkeypatch.setattr("services.listing_cache._get_redis_client", lambda: fake_redis) + + +def _make_qp(**kwargs) -> QueryParameters: + return QueryParameters(listing_type=ListingType.RENT, **kwargs) + + +def _sample_features(n: int) -> list[dict]: + return [{"type": "Feature", "id": i, "properties": {"price": 1000 + i}} for i in range(n)] + + +# ---------- Basic read/write ---------- + + +def test_cache_miss_returns_none() -> None: + qp = _make_qp() + assert get_cached_count(qp) is None + + +def test_cache_write_then_read() -> None: + qp = _make_qp() + features = _sample_features(5) + cache_features_batch(qp, features) + + count = get_cached_count(qp) + assert count == 5 + + +def test_batch_retrieval() -> None: + qp = _make_qp() + cache_features_batch(qp, _sample_features(10)) + + batches = list(get_cached_features(qp, batch_size=3)) + sizes = [len(b) for b in batches] + assert sizes == [3, 3, 3, 1] + + +# ---------- Cache key behaviour ---------- + + +def test_cache_key_deterministic() -> None: + qp1 = _make_qp() + qp2 = _make_qp() + assert make_cache_key(qp1) == make_cache_key(qp2) + + +def test_cache_key_different_for_different_params() -> None: + rent = _make_qp() + buy = QueryParameters(listing_type=ListingType.BUY) + assert make_cache_key(rent) != make_cache_key(buy) + + +# ---------- Staged population ---------- + + +def test_staged_population_begin() -> None: + qp = _make_qp() + staging_key = begin_cache_population(qp) + assert isinstance(staging_key, str) + assert "staging" in staging_key + + +def test_staged_write_then_finalize() -> None: + qp = _make_qp() + staging_key = begin_cache_population(qp) + cache_features_batch_staged(staging_key, _sample_features(4)) + finalize_cache_population(staging_key, qp) + + assert get_cached_count(qp) == 4 + + +def test_staging_key_deleted_on_cleanup(fake_redis) -> None: + qp = _make_qp() + staging_key = begin_cache_population(qp) + cache_features_batch_staged(staging_key, _sample_features(2)) + delete_staging_key(staging_key) + + assert fake_redis.exists(staging_key) == 0 + + +# ---------- Invalidation ---------- + + +def test_invalidation() -> None: + qp = _make_qp() + cache_features_batch(qp, _sample_features(5)) + assert get_cached_count(qp) == 5 + + invalidate_cache() + assert get_cached_count(qp) is None + + +# ---------- Edge cases ---------- + + +def test_empty_features_batch_noop() -> None: + qp = _make_qp() + cache_features_batch(qp, []) + assert get_cached_count(qp) is None + + +def test_multiple_batches_accumulate() -> None: + qp = _make_qp() + cache_features_batch(qp, _sample_features(3)) + cache_features_batch(qp, _sample_features(4)) + assert get_cached_count(qp) == 7 + + +def test_get_cached_features_empty() -> None: + qp = _make_qp() + batches = list(get_cached_features(qp)) + assert batches == [] diff --git a/tests/integration/test_listing_processor.py b/tests/integration/test_listing_processor.py new file mode 100644 index 0000000..ed3ab3a --- /dev/null +++ b/tests/integration/test_listing_processor.py @@ -0,0 +1,186 @@ +"""Integration tests for ListingProcessor and processing steps.""" +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import Engine + +from listing_processor import ( + DetectFloorplanStep, + FetchImagesStep, + FetchListingDetailsStep, + ListingProcessor, +) +from models.listing import ListingType +from repositories.listing_repository import ListingRepository + + +# ---------- Processor structure tests ---------- + + +def test_processor_has_three_steps(listing_repository: ListingRepository) -> None: + processor = ListingProcessor(listing_repository) + assert len(processor.process_steps) == 3 + + +def test_step_order(listing_repository: ListingRepository) -> None: + processor = ListingProcessor(listing_repository) + types = [type(s) for s in processor.process_steps] + assert types == [FetchListingDetailsStep, FetchImagesStep, DetectFloorplanStep] + + +# ---------- Processing flow ---------- + + +@pytest.mark.asyncio +async def test_process_calls_steps_in_order( + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + # Seed a listing so mark_seen doesn't fail + listing = rent_listing_factory(id=42) + await listing_repository.upsert_listings([listing]) + + processor = ListingProcessor(listing_repository) + + call_order: list[str] = [] + for step in processor.process_steps: + name = type(step).__name__ + step.needs_processing = AsyncMock(return_value=True) + step.process = AsyncMock( + side_effect=lambda lid, n=name: call_order.append(n) or listing + ) + + result = await processor.process_listing(42) + assert result is not None + assert call_order == [ + "FetchListingDetailsStep", + "FetchImagesStep", + "DetectFloorplanStep", + ] + + +@pytest.mark.asyncio +async def test_step_failure_stops_pipeline( + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listing = rent_listing_factory(id=42) + await listing_repository.upsert_listings([listing]) + + processor = ListingProcessor(listing_repository) + + processor.process_steps[0].needs_processing = AsyncMock(return_value=True) + processor.process_steps[0].process = AsyncMock(side_effect=RuntimeError("boom")) + processor.process_steps[1].needs_processing = AsyncMock(return_value=True) + processor.process_steps[1].process = AsyncMock() + processor.process_steps[2].needs_processing = AsyncMock(return_value=True) + processor.process_steps[2].process = AsyncMock() + + result = await processor.process_listing(42) + assert result is None + # Second and third steps should not have been called + processor.process_steps[1].process.assert_not_called() + processor.process_steps[2].process.assert_not_called() + + +@pytest.mark.asyncio +async def test_callback_fired_per_step( + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listing = rent_listing_factory(id=42) + await listing_repository.upsert_listings([listing]) + + processor = ListingProcessor(listing_repository) + + for step in processor.process_steps: + step.needs_processing = AsyncMock(return_value=True) + step.process = AsyncMock(return_value=listing) + + callback_args: list[str] = [] + await processor.process_listing(42, on_step_complete=lambda name: callback_args.append(name)) + + assert callback_args == ["details", "images", "ocr"] + + +@pytest.mark.asyncio +async def test_step_skipped_when_not_needed( + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + listing = rent_listing_factory(id=42) + await listing_repository.upsert_listings([listing]) + + processor = ListingProcessor(listing_repository) + + for step in processor.process_steps: + step.needs_processing = AsyncMock(return_value=False) + step.process = AsyncMock() + + await processor.process_listing(42) + + for step in processor.process_steps: + step.process.assert_not_called() + + +# ---------- Individual step tests ---------- + + +@pytest.mark.asyncio +async def test_fetch_details_creates_listing( + listing_repository: ListingRepository, + monkeypatch: pytest.MonkeyPatch, +) -> None: + sample_detail = { + "property": { + "price": 2000, + "bedrooms": 2, + "branch": {"brandName": "Test Agency"}, + "councilTaxInfo": {"content": [{"value": "C"}]}, + "longitude": -0.1, + "latitude": 51.5, + "photos": [{"thumbnailUrl": "https://example.com/photo.jpg"}], + "floorplans": [], + "letFurnishType": "furnished", + "letDateAvailable": "Now", + "visible": True, + } + } + monkeypatch.setattr("listing_processor.detail_query", AsyncMock(return_value=sample_detail)) + + step = FetchListingDetailsStep(listing_repository, ListingType.RENT) + result = await step.process(999) + + assert result is not None + assert result.id == 999 + assert result.price == 2000 + + # Verify it was persisted + stored = await listing_repository.get_listings(only_ids=[999]) + assert len(stored) == 1 + + +@pytest.mark.asyncio +async def test_processor_marks_seen( + listing_repository: ListingRepository, + rent_listing_factory, +) -> None: + from datetime import datetime, timedelta + + old_time = datetime(2020, 1, 1) + listing = rent_listing_factory(id=50, last_seen=old_time) + await listing_repository.upsert_listings([listing]) + + processor = ListingProcessor(listing_repository) + + # Skip all steps so we only test mark_seen + for step in processor.process_steps: + step.needs_processing = AsyncMock(return_value=False) + step.process = AsyncMock() + + await processor.process_listing(50) + + updated = await listing_repository.get_listings(only_ids=[50]) + assert len(updated) == 1 + # last_seen should have been updated to roughly now + assert updated[0].last_seen > old_time diff --git a/tests/integration/test_repository_advanced.py b/tests/integration/test_repository_advanced.py new file mode 100644 index 0000000..7ca0254 --- /dev/null +++ b/tests/integration/test_repository_advanced.py @@ -0,0 +1,122 @@ +"""Advanced integration tests for ListingRepository.""" +import asyncio + +import pytest +from sqlalchemy import Engine + +from models.listing import FurnishType, ListingType, QueryParameters +from repositories.listing_repository import ListingRepository + + +# ---------- Count and basic queries ---------- + + +@pytest.mark.asyncio +async def test_count_matches_get_listings(seeded_repository: ListingRepository) -> None: + qp = QueryParameters(listing_type=ListingType.RENT) + count = seeded_repository.count_listings(qp) + listings = await seeded_repository.get_listings(query_parameters=qp) + assert count == len(listings) + + +@pytest.mark.asyncio +async def test_stream_with_small_page_size(seeded_repository: ListingRepository) -> None: + qp = QueryParameters(listing_type=ListingType.RENT) + rows = list(seeded_repository.stream_listings_optimized(qp, page_size=3)) + assert len(rows) == 10 + + +# ---------- Filter tests ---------- + + +@pytest.mark.asyncio +async def test_filter_by_bedrooms(seeded_repository: ListingRepository) -> None: + qp = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2, max_bedrooms=2) + listings = await seeded_repository.get_listings(query_parameters=qp) + for listing in listings: + assert listing.number_of_bedrooms == 2 + assert len(listings) > 0 + + +@pytest.mark.asyncio +async def test_filter_by_price_range(seeded_repository: ListingRepository) -> None: + qp = QueryParameters(listing_type=ListingType.RENT, min_price=1500, max_price=2500) + listings = await seeded_repository.get_listings(query_parameters=qp) + for listing in listings: + assert 1500 <= listing.price <= 2500 + assert len(listings) > 0 + + +@pytest.mark.asyncio +async def test_filter_by_max_sqm(seeded_repository: ListingRepository) -> None: + qp = QueryParameters(listing_type=ListingType.RENT, max_sqm=50) + listings = await seeded_repository.get_listings(query_parameters=qp) + for listing in listings: + assert listing.square_meters is not None + assert listing.square_meters <= 50 + assert len(listings) > 0 + + +@pytest.mark.asyncio +async def test_filter_by_furnish_type( + in_memory_engine: Engine, + rent_listing_factory, +) -> None: + repo = ListingRepository(engine=in_memory_engine) + furnished = rent_listing_factory(id=1, furnish_type=FurnishType.FURNISHED) + unfurnished = rent_listing_factory(id=2, furnish_type=FurnishType.UNFURNISHED) + await repo.upsert_listings([furnished, unfurnished]) + + qp = QueryParameters( + listing_type=ListingType.RENT, + furnish_types=[FurnishType.FURNISHED], + ) + listings = await repo.get_listings(query_parameters=qp) + assert len(listings) == 1 + assert listings[0].furnish_type == FurnishType.FURNISHED + + +# ---------- Concurrency ---------- + + +@pytest.mark.asyncio +async def test_concurrent_upserts( + in_memory_engine: Engine, + rent_listing_factory, +) -> None: + repo = ListingRepository(engine=in_memory_engine) + + async def upsert_batch(start_id: int) -> None: + listings = [rent_listing_factory(id=start_id + i) for i in range(5)] + await repo.upsert_listings(listings) + + await asyncio.gather( + upsert_batch(1000), + upsert_batch(2000), + upsert_batch(3000), + upsert_batch(4000), + upsert_batch(5000), + ) + + qp = QueryParameters(listing_type=ListingType.RENT) + total = repo.count_listings(qp) + assert total == 25 + + +# ---------- Streaming ---------- + + +@pytest.mark.asyncio +async def test_stream_optimized_returns_dicts(seeded_repository: ListingRepository) -> None: + qp = QueryParameters(listing_type=ListingType.RENT) + rows = list(seeded_repository.stream_listings_optimized(qp)) + + assert len(rows) > 0 + for row in rows: + assert isinstance(row, dict) + assert "id" in row + assert "price" in row + assert "number_of_bedrooms" in row + assert "square_meters" in row + assert "longitude" in row + assert "latitude" in row diff --git a/tests/regression/__init__.py b/tests/regression/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/regression/test_api_contracts.py b/tests/regression/test_api_contracts.py new file mode 100644 index 0000000..a9f5367 --- /dev/null +++ b/tests/regression/test_api_contracts.py @@ -0,0 +1,135 @@ +"""Regression tests for API response contracts. + +These tests lock down the shape of API responses to prevent +accidental breaking changes. +""" + +import json +import pytest +from httpx import ASGITransport, AsyncClient +from unittest.mock import MagicMock, patch, AsyncMock + + +pytestmark = pytest.mark.regression + + +@pytest.fixture(autouse=True) +def disable_rate_limiting(monkeypatch): + """Disable rate limiting for all regression tests.""" + import api.rate_limiter + monkeypatch.setattr(api.rate_limiter, "_match_endpoint", lambda path, config: None) + + +@pytest.fixture +async def unauthenticated_client(in_memory_engine): + """Client without mock auth — requests should be rejected.""" + from api.app import app + import database + + original_engine = database.engine + database.engine = in_memory_engine + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield client + database.engine = original_engine + app.dependency_overrides.clear() + + +class TestStatusEndpoint: + @pytest.mark.asyncio + async def test_status_returns_ok(self, async_client): + response = await async_client.get("/api/status") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "OK" + + +class TestListingEndpoint: + @pytest.mark.asyncio + async def test_listing_has_listings_key(self, async_client): + response = await async_client.get("/api/listing?limit=5") + assert response.status_code == 200 + data = response.json() + assert "listings" in data + + +class TestListingGeojsonEndpoint: + @pytest.mark.asyncio + async def test_listing_geojson_has_feature_collection_shape(self, async_client): + response = await async_client.get("/api/listing_geojson?listing_type=RENT") + assert response.status_code == 200 + data = response.json() + assert data.get("type") == "FeatureCollection" + assert "features" in data + + +class TestStreamEndpoint: + @pytest.mark.asyncio + async def test_stream_first_line_is_metadata(self, async_client): + response = await async_client.get("/api/listing_geojson/stream?listing_type=RENT") + assert response.status_code == 200 + lines = [line for line in response.text.strip().split("\n") if line.strip()] + assert len(lines) >= 1 + first = json.loads(lines[0]) + assert first.get("type") == "metadata" + assert "batch_size" in first + assert "total_expected" in first + assert "cached" in first + + @pytest.mark.asyncio + async def test_stream_last_line_is_complete(self, async_client): + response = await async_client.get("/api/listing_geojson/stream?listing_type=RENT") + assert response.status_code == 200 + lines = [line for line in response.text.strip().split("\n") if line.strip()] + assert len(lines) >= 1 + last = json.loads(lines[-1]) + assert last.get("type") == "complete" + assert "total" in last + + +class TestTaskStatusEndpoint: + @pytest.mark.asyncio + async def test_task_status_response_shape(self, async_client): + from services.task_service import TaskStatus + mock_status = TaskStatus( + task_id="test-123", + status="SUCCESS", + result=None, + progress=1.0, + processed=10, + total=10, + message="Done", + error=None, + traceback=None, + ) + with patch("services.task_service.get_task_status", return_value=mock_status), \ + patch("services.task_service.get_user_tasks", return_value=["test-123"]): + response = await async_client.get("/api/task_status?task_id=test-123") + assert response.status_code == 200 + data = response.json() + for key in ["task_id", "status", "result", "progress", "processed", "total", "message", "error", "traceback"]: + assert key in data, f"Missing key: {key}" + + +class TestUnauthenticatedAccess: + @pytest.mark.asyncio + @pytest.mark.parametrize("method,path", [ + ("GET", "/api/listing"), + ("GET", "/api/listing_geojson?listing_type=RENT"), + ("GET", "/api/listing_geojson/stream?listing_type=RENT"), + ("GET", "/api/task_status?task_id=test"), + ("GET", "/api/tasks_for_user"), + ("POST", "/api/refresh_listings?listing_type=RENT"), + ("POST", "/api/cancel_task?task_id=test"), + ("POST", "/api/clear_all_tasks"), + ]) + async def test_unauthenticated_endpoints_return_error( + self, unauthenticated_client, method, path + ): + if method == "GET": + response = await unauthenticated_client.get(path) + else: + response = await unauthenticated_client.post(path) + assert response.status_code in (401, 403), ( + f"{method} {path} returned {response.status_code}, expected 401 or 403" + ) diff --git a/tests/regression/test_query_parameters.py b/tests/regression/test_query_parameters.py new file mode 100644 index 0000000..101e81d --- /dev/null +++ b/tests/regression/test_query_parameters.py @@ -0,0 +1,75 @@ +"""Regression tests for QueryParameters model and API query parsing.""" + +import pytest +from datetime import datetime, timezone +from httpx import ASGITransport, AsyncClient +from unittest.mock import patch + +from models.listing import QueryParameters, ListingType, FurnishType + + +pytestmark = pytest.mark.regression + + +class TestQueryParametersModel: + def test_defaults_applied(self): + params = QueryParameters(listing_type=ListingType.RENT) + assert params.min_bedrooms == 1 + assert params.max_bedrooms == 999 + assert params.listing_type == ListingType.RENT + + def test_datetime_z_suffix_parsing(self): + params = QueryParameters( + listing_type=ListingType.RENT, + let_date_available_from="2024-01-15T00:00:00Z", + ) + assert params.let_date_available_from is not None + assert isinstance(params.let_date_available_from, datetime) + + def test_datetime_offset_parsing(self): + params = QueryParameters( + listing_type=ListingType.RENT, + let_date_available_from="2024-01-15T00:00:00+00:00", + ) + assert params.let_date_available_from is not None + assert isinstance(params.let_date_available_from, datetime) + + def test_min_price_greater_than_max_raises(self): + with pytest.raises((ValueError, Exception)): + QueryParameters( + listing_type=ListingType.RENT, + min_price=5000, + max_price=1000, + ) + + def test_min_bedrooms_greater_than_max_raises(self): + with pytest.raises((ValueError, Exception)): + QueryParameters( + listing_type=ListingType.RENT, + min_bedrooms=5, + max_bedrooms=2, + ) + + +class TestQueryParametersApiParsing: + @pytest.mark.asyncio + async def test_comma_separated_furnish_types(self, async_client): + response = await async_client.get( + "/api/listing?listing_type=RENT&furnish_types=furnished,unfurnished" + ) + # If the endpoint accepts the param, it should return 200 + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_comma_separated_district_names(self, async_client): + response = await async_client.get( + "/api/listing?listing_type=RENT&district_names=London,Camden" + ) + assert response.status_code == 200 + + @pytest.mark.asyncio + async def test_invalid_listing_type_returns_422(self, async_client): + response = await async_client.get( + "/api/listing_geojson?listing_type=INVALID_TYPE" + ) + assert response.status_code == 422 diff --git a/tests/unit/test_district_service.py b/tests/unit/test_district_service.py new file mode 100644 index 0000000..d75366f --- /dev/null +++ b/tests/unit/test_district_service.py @@ -0,0 +1,34 @@ +"""Unit tests for services/district_service.py.""" + +import pytest + +from services import district_service + + +class TestGetAllDistricts: + def test_get_all_districts_returns_dict(self): + result = district_service.get_all_districts() + assert isinstance(result, dict) + assert len(result) > 0 + + +class TestGetDistrictNames: + def test_get_district_names_returns_list(self): + result = district_service.get_district_names() + assert isinstance(result, list) + assert len(result) > 0 + + +class TestValidateDistricts: + def test_validate_districts_all_valid(self): + result = district_service.validate_districts(["London", "Westminster"]) + assert result == [] + + def test_validate_districts_returns_invalid(self): + result = district_service.validate_districts(["London", "Narnia"]) + assert "Narnia" in result + + def test_known_districts_present(self): + names = district_service.get_district_names() + for district in ["London", "Westminster", "Camden"]: + assert district in names diff --git a/tests/unit/test_export_service.py b/tests/unit/test_export_service.py new file mode 100644 index 0000000..e2513b6 --- /dev/null +++ b/tests/unit/test_export_service.py @@ -0,0 +1,87 @@ +"""Unit tests for services/export_service.py.""" + +import pytest +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from models.listing import QueryParameters, ListingType +from services import export_service + + +class TestExportToCsv: + async def test_csv_export_calls_exporter(self, listing_repository, tmp_path): + output_path = tmp_path / "output.csv" + with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv: + mock_csv.return_value = None + result = await export_service.export_to_csv(listing_repository, output_path) + mock_csv.assert_called_once() + assert result.success + + async def test_csv_export_returns_correct_record_count(self, listing_repository, sample_rent_listings, tmp_path): + await listing_repository.upsert_listings(sample_rent_listings) + output_path = tmp_path / "output.csv" + with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv: + mock_csv.return_value = None + result = await export_service.export_to_csv(listing_repository, output_path) + assert result.record_count == len(sample_rent_listings) + + async def test_csv_export_passes_query_parameters(self, listing_repository, tmp_path): + output_path = tmp_path / "output.csv" + params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2) + with patch("csv_exporter.export_to_csv", new_callable=AsyncMock) as mock_csv: + mock_csv.return_value = None + result = await export_service.export_to_csv( + listing_repository, output_path, query_parameters=params + ) + assert result.success + assert str(output_path) in result.output_path + + +class TestExportToGeojson: + async def test_geojson_in_memory_returns_data(self, listing_repository): + fake_geojson = {"type": "FeatureCollection", "features": [{"type": "Feature"}]} + with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export: + mock_export.return_value = fake_geojson + result = await export_service.export_to_geojson(listing_repository) + assert result.data is not None + assert result.data["type"] == "FeatureCollection" + + async def test_geojson_file_export_returns_path(self, listing_repository, tmp_path): + output_path = tmp_path / "output.geojson" + fake_geojson = {"type": "FeatureCollection", "features": []} + with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export: + mock_export.return_value = fake_geojson + result = await export_service.export_to_geojson( + listing_repository, output_path=output_path + ) + assert result.output_path is not None + assert result.data is None + + async def test_geojson_with_filters(self, listing_repository): + params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=2) + fake_geojson = {"type": "FeatureCollection", "features": []} + with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export: + mock_export.return_value = fake_geojson + result = await export_service.export_to_geojson( + listing_repository, query_parameters=params + ) + assert result.success + mock_export.assert_called_once() + + async def test_geojson_with_limit(self, listing_repository): + fake_geojson = {"type": "FeatureCollection", "features": []} + with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export: + mock_export.return_value = fake_geojson + result = await export_service.export_to_geojson( + listing_repository, limit=5 + ) + assert result.success + _, kwargs = mock_export.call_args + assert kwargs.get("limit") == 5 + + async def test_geojson_empty_data(self, listing_repository): + fake_geojson = {"type": "FeatureCollection", "features": []} + with patch("ui_exporter.export_immoweb", new_callable=AsyncMock) as mock_export: + mock_export.return_value = fake_geojson + result = await export_service.export_to_geojson(listing_repository) + assert result.record_count == 0 diff --git a/tests/unit/test_listing_service.py b/tests/unit/test_listing_service.py new file mode 100644 index 0000000..f5c43cb --- /dev/null +++ b/tests/unit/test_listing_service.py @@ -0,0 +1,129 @@ +"""Unit tests for services/listing_service.py.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from models.listing import QueryParameters, ListingType +from services import listing_service + + +class TestGetListings: + async def test_empty_db_returns_zero(self, listing_repository): + result = await listing_service.get_listings(listing_repository) + assert result.total_count == 0 + assert result.listings == [] + + async def test_with_results_returns_correct_count(self, listing_repository, sample_rent_listings): + await listing_repository.upsert_listings(sample_rent_listings) + result = await listing_service.get_listings(listing_repository) + assert result.total_count == len(sample_rent_listings) + assert len(result.listings) == len(sample_rent_listings) + + async def test_with_query_parameters_filters(self, listing_repository, sample_rent_listings): + await listing_repository.upsert_listings(sample_rent_listings) + params = QueryParameters(listing_type=ListingType.RENT, min_bedrooms=3) + result = await listing_service.get_listings(listing_repository, query_parameters=params) + for listing in result.listings: + assert listing.number_of_bedrooms >= 3 + + async def test_limit_works(self, listing_repository, sample_rent_listings): + await listing_repository.upsert_listings(sample_rent_listings) + result = await listing_service.get_listings(listing_repository, limit=1) + assert len(result.listings) <= 1 + + async def test_only_ids_works(self, listing_repository, sample_rent_listings): + await listing_repository.upsert_listings(sample_rent_listings) + target_ids = [sample_rent_listings[0].id] + result = await listing_service.get_listings(listing_repository, only_ids=target_ids) + assert all(l.id in target_ids for l in result.listings) + + +class TestRefreshListings: + async def test_async_mode_dispatches_celery_task(self, listing_repository): + params = QueryParameters(listing_type=ListingType.RENT) + with patch("tasks.listing_tasks.dump_listings_task") as mock_task: + mock_task.apply_async.return_value = MagicMock(id="fake-task-id") + result = await listing_service.refresh_listings( + listing_repository, params, async_mode=True, user_email="test@example.com" + ) + mock_task.apply_async.assert_called_once() + assert result.task_id == "fake-task-id" + + async def test_sync_mode_calls_fetcher(self, listing_repository): + params = QueryParameters(listing_type=ListingType.RENT) + with patch("services.listing_fetcher.dump_listings", new_callable=AsyncMock) as mock_dump: + mock_dump.return_value = [] + result = await listing_service.refresh_listings( + listing_repository, params, async_mode=False + ) + mock_dump.assert_called_once() + assert result.task_id is None + + async def test_full_mode_calls_dump_listings_full(self, listing_repository): + params = QueryParameters(listing_type=ListingType.RENT) + with patch("services.listing_fetcher.dump_listings_full", new_callable=AsyncMock) as mock_full: + mock_full.return_value = [] + result = await listing_service.refresh_listings( + listing_repository, params, full=True, async_mode=False + ) + mock_full.assert_called_once() + + async def test_sync_returns_new_listings_count(self, listing_repository): + params = QueryParameters(listing_type=ListingType.RENT) + fake_listings = [MagicMock(), MagicMock(), MagicMock()] + with patch("services.listing_fetcher.dump_listings", new_callable=AsyncMock) as mock_dump: + mock_dump.return_value = fake_listings + result = await listing_service.refresh_listings( + listing_repository, params, async_mode=False + ) + assert result.new_listings_count == 3 + + async def test_async_result_has_message(self, listing_repository): + params = QueryParameters(listing_type=ListingType.RENT) + with patch("tasks.listing_tasks.dump_listings_task") as mock_task: + mock_task.apply_async.return_value = MagicMock(id="fake-task-id") + result = await listing_service.refresh_listings( + listing_repository, params, async_mode=True + ) + assert result.message is not None + assert len(result.message) > 0 + + +class TestDownloadImages: + async def test_calls_image_fetcher(self, listing_repository): + with patch("services.image_fetcher.dump_images", new_callable=AsyncMock) as mock_dump: + mock_dump.return_value = None + result = await listing_service.download_images(listing_repository) + mock_dump.assert_called_once() + + +class TestDetectFloorplans: + async def test_calls_floorplan_detector(self, listing_repository): + with patch("services.floorplan_detector.detect_floorplan", new_callable=AsyncMock) as mock_detect: + mock_detect.return_value = None + result = await listing_service.detect_floorplans(listing_repository) + mock_detect.assert_called_once() + + +class TestCalculateRoutes: + async def test_passes_correct_travel_mode(self, listing_repository): + with patch("services.route_calculator.calculate_route", new_callable=AsyncMock) as mock_calc: + mock_calc.return_value = None + result = await listing_service.calculate_routes( + listing_repository, + destination_address="London Bridge", + travel_mode="TRANSIT", + limit=10, + ) + mock_calc.assert_called_once() + + async def test_passes_limit(self, listing_repository): + with patch("services.route_calculator.calculate_route", new_callable=AsyncMock) as mock_calc: + mock_calc.return_value = None + result = await listing_service.calculate_routes( + listing_repository, + destination_address="Kings Cross", + travel_mode="TRANSIT", + limit=5, + ) + assert result == 5