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