diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1b41ce5d..ea0bddde 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,9 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.6", "@types/crypto-js": "^4.2.2", "@types/jest": "^30.0.0", "@types/lodash": "^4.17.21", @@ -61,6 +64,7 @@ "glob": "^13.0.0", "globals": "^16.5.0", "husky": "^9.1.7", + "jsdom": "^30.0.1", "lint-staged": "^16.2.7", "prettier": "3.6.2", "typescript": "~5.9.3", @@ -71,10 +75,70 @@ "vitest": "^4.0.18" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@albedo-link/intent": { "version": "0.12.0", "license": "MIT" }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "dev": true, @@ -342,6 +406,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@creit.tech/stellar-wallets-kit": { "version": "1.9.5", "dependencies": { @@ -447,6 +524,146 @@ "node": ">=16" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emurgo/cardano-serialization-lib-browser": { "version": "13.2.1", "license": "MIT" @@ -3647,6 +3864,154 @@ "react": "^18 || ^19" } }, + "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/dom/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", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/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/@testing-library/dom/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/@testing-library/dom/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/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "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": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "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.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@trezor/analytics": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@trezor/analytics/-/analytics-1.5.0.tgz", @@ -4587,6 +4952,14 @@ "tslib": "^2.6.2" } }, + "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/babel__core": { "version": "7.20.5", "dev": true, @@ -5725,6 +6098,16 @@ "dev": true, "license": "Python-2.0" }, + "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/asn1.js": { "version": "4.10.1", "license": "MIT", @@ -5904,6 +6287,16 @@ "version": "2.0.0", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big-integer": { "version": "1.6.36", "license": "Unlicense", @@ -6780,6 +7173,27 @@ "tiny-invariant": "^1.0.6" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "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/csstype": { "version": "3.2.3", "license": "MIT" @@ -6905,9 +7319,79 @@ "node": ">=12" } }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", "funding": { @@ -6937,6 +7421,13 @@ "node": ">=0.10.0" } }, + "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/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -7030,6 +7521,16 @@ "node": ">= 0.8" } }, + "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/des.js": { "version": "1.1.0", "license": "MIT", @@ -7090,6 +7591,14 @@ "version": "1.0.3", "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/domain-browser": { "version": "4.22.0", "dev": true, @@ -7209,6 +7718,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "dev": true, @@ -8226,6 +8748,37 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-encoding-sniffer/node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "license": "MIT", @@ -8387,6 +8940,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/inherits": { "version": "2.0.4", "license": "ISC" @@ -8572,6 +9135,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/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -9041,6 +9611,113 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "dev": true, @@ -9609,6 +10286,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.21", "license": "MIT", @@ -9632,6 +10320,13 @@ "safe-buffer": "^5.1.2" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromatch": { "version": "4.0.8", "dev": true, @@ -9687,6 +10382,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/minimalistic-assert": { "version": "1.0.1", "license": "ISC" @@ -10215,6 +10920,19 @@ "node": ">= 0.10" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "dev": true, @@ -11114,6 +11832,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "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/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -11147,6 +11879,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "license": "ISC" @@ -11365,6 +12107,19 @@ "node": ">=10" } }, + "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.27.0", "license": "MIT" @@ -11841,6 +12596,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "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", "dev": true, @@ -11884,6 +12652,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/tailwindcss": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", @@ -12015,6 +12790,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "license": "MIT", @@ -12056,6 +12851,19 @@ "version": "3.0.0", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "license": "MIT" @@ -12288,6 +13096,16 @@ "version": "0.1.3", "license": "MIT" }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "license": "MIT" @@ -12802,10 +13620,33 @@ "node": ">=0.10.0" } }, + "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": "3.0.1", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "license": "MIT", @@ -12906,6 +13747,23 @@ } } }, + "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/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 33e58d67..704cfe7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,8 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:headed": "playwright test --headed", - "test:e2e:report": "playwright show-report" + "test:e2e:report": "playwright show-report", + "test": "vitest run" }, "workspaces": [ "packages/*" @@ -59,6 +60,9 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@playwright/test": "^1.61.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.6", "@types/crypto-js": "^4.2.2", "@types/jest": "^30.0.0", "@types/lodash": "^4.17.21", @@ -78,6 +82,7 @@ "glob": "^13.0.0", "globals": "^16.5.0", "husky": "^9.1.7", + "jsdom": "^30.0.1", "lint-staged": "^16.2.7", "prettier": "3.6.2", "typescript": "~5.9.3", diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts new file mode 100644 index 00000000..ec6489d2 --- /dev/null +++ b/frontend/src/__tests__/setup.ts @@ -0,0 +1,20 @@ +// Set NODE_ENV before anything else to use React 19 development builds +process.env.NODE_ENV = 'test'; +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach, vi } from 'vitest'; + +afterEach(() => { + cleanup(); +}); + +// Mock react-i18next — used by hooks that call useTranslation +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, +})); diff --git a/frontend/src/__tests__/useAutosave.test.ts b/frontend/src/__tests__/useAutosave.test.ts new file mode 100644 index 00000000..e7b0a7db --- /dev/null +++ b/frontend/src/__tests__/useAutosave.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAutosave } from '../hooks/useAutosave'; + +describe('useAutosave', () => { + const TEST_KEY = 'test:autosave:key'; + + beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('saves data to localStorage after the debounce delay', async () => { + const { result } = renderHook(() => useAutosave(TEST_KEY, 'hello', 500)); + + // Initially not saving + expect(result.current.saving).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(result.current.saving).toBe(false); + expect(localStorage.getItem(TEST_KEY)).toBe(JSON.stringify('hello')); + expect(result.current.lastSaved).toBeInstanceOf(Date); + }); + + it('does not save before the debounce delay elapses', () => { + renderHook(() => useAutosave(TEST_KEY, 'pending', 500)); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(localStorage.getItem(TEST_KEY)).toBeNull(); + }); + + it('re-saves when data changes', async () => { + const { rerender } = renderHook(({ value }) => useAutosave(TEST_KEY, value, 500), { + initialProps: { value: 'first' }, + }); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + expect(localStorage.getItem(TEST_KEY)).toBe(JSON.stringify('first')); + + rerender({ value: 'second' }); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + expect(localStorage.getItem(TEST_KEY)).toBe(JSON.stringify('second')); + }); + + it('handles localStorage write errors gracefully', async () => { + const originalSetItem = Storage.prototype.setItem; + const setItemMock = vi.fn(() => { + throw new Error('QuotaExceededError'); + }); + Storage.prototype.setItem = setItemMock; + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const { result } = renderHook(() => useAutosave(TEST_KEY, 'data', 500)); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(result.current.saving).toBe(false); + expect(result.current.lastSaved).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error autosaving data'), + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + Storage.prototype.setItem = originalSetItem; + }); + + it('cancels the pending save when unmounted before the delay', () => { + const { unmount } = renderHook(() => useAutosave(TEST_KEY, 'gone', 500)); + + act(() => { + unmount(); + vi.advanceTimersByTime(1000); + }); + + expect(localStorage.getItem(TEST_KEY)).toBeNull(); + }); + + it('loads saved data from localStorage via loadSavedData', () => { + localStorage.setItem(TEST_KEY, JSON.stringify({ name: 'Alice' })); + + const { result } = renderHook(() => useAutosave<{ name: string }>(TEST_KEY, null as unknown as { name: string })); + + expect(result.current.loadSavedData()).toEqual({ name: 'Alice' }); + }); + + it('returns null from loadSavedData when no saved data exists', () => { + const { result } = renderHook(() => useAutosave(TEST_KEY, '')); + + expect(result.current.loadSavedData()).toBeNull(); + }); + + it('returns null from loadSavedData on parse errors', () => { + localStorage.setItem(TEST_KEY, '{invalid-json'); + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + const { result } = renderHook(() => useAutosave(TEST_KEY, '')); + + expect(result.current.loadSavedData()).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error loading autosave data'), + expect.any(Error) + ); + + consoleErrorSpy.mockRestore(); + }); + + it('clears saved data with clearSavedData', async () => { + localStorage.setItem(TEST_KEY, JSON.stringify('value')); + const { result } = renderHook(() => useAutosave(TEST_KEY, 'value', 500)); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + expect(localStorage.getItem(TEST_KEY)).not.toBeNull(); + + act(() => { + result.current.clearSavedData(); + }); + + expect(localStorage.getItem(TEST_KEY)).toBeNull(); + expect(result.current.lastSaved).toBeNull(); + }); + + it('uses the default debounce delay of 1000ms', async () => { + const { result } = renderHook(() => useAutosave(TEST_KEY, 'default-delay')); + + expect(result.current.saving).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(999); + }); + expect(localStorage.getItem(TEST_KEY)).toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(localStorage.getItem(TEST_KEY)).toBe(JSON.stringify('default-delay')); + expect(result.current.saving).toBe(false); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/useEmployeePortal.test.ts b/frontend/src/__tests__/useEmployeePortal.test.ts new file mode 100644 index 00000000..b7f2148b --- /dev/null +++ b/frontend/src/__tests__/useEmployeePortal.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useEmployeePortal } from '../hooks/useEmployeePortal'; + +// Mock services used by useEmployeePortal +const { mockFetchExchangeRates, mockGetStellarExpertLink, mockGetMyDeductionsDraftPayslip } = vi.hoisted(() => ({ + mockFetchExchangeRates: vi.fn(), + mockGetStellarExpertLink: vi.fn(), + mockGetMyDeductionsDraftPayslip: vi.fn(), +})); + +vi.mock('../services/currencyConversion', () => ({ + fetchExchangeRates: (...args: unknown[]) => mockFetchExchangeRates(...args), + getStellarExpertLink: (...args: unknown[]) => mockGetStellarExpertLink(...args), +})); + +vi.mock('../services/benefitsApi', () => ({ + getMyDeductionsDraftPayslip: (...args: unknown[]) => mockGetMyDeductionsDraftPayslip(...args), +})); + +describe('useEmployeePortal', () => { + beforeEach(() => { + mockFetchExchangeRates.mockResolvedValue({ NGN: 1500, USD: 1, EUR: 0.85 }); + mockGetStellarExpertLink.mockReturnValue('https://stellar.expert/explorer/testnet/tx/hash'); + mockGetMyDeductionsDraftPayslip.mockResolvedValue({ + organization_id: 1, + employee_id: 42, + currency: 'ORGUSD', + gross_amount: 2500, + lines: [], + total_deductions: 500, + net_amount: 2000, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads data on mount and sets balance', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + // Initially loading + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + + // Wait for the async data loading to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + expect(result.current.balance).not.toBeNull(); + expect(result.current.balance?.orgUsd).toBeGreaterThan(0); + expect(result.current.balance?.localCurrency).toBe('NGN'); + expect(result.current.deductionsDraft).not.toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it('sets error when data loading fails', async () => { + mockFetchExchangeRates.mockRejectedValue(new Error('API unavailable')); + + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + expect(result.current.error).toBe('API unavailable'); + expect(result.current.balance).toBeNull(); + }); + + it('shows paginated transactions with default 8 items per page', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + // 12 mock transactions, 8 per page = 2 pages + expect(result.current.transactions.length).toBeLessThanOrEqual(8); + expect(result.current.totalPages).toBe(2); + expect(result.current.currentPage).toBe(1); + }); + + it('navigates pages with setCurrentPage', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + await act(async () => { + result.current.setCurrentPage(2); + }); + expect(result.current.currentPage).toBe(2); + }); + + it('filters transactions by status', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + await act(async () => { + result.current.setFilterStatus('pending'); + }); + expect(result.current.filterStatus).toBe('pending'); + const allPending = result.current.filteredTransactions.every( + (tx) => tx.status === 'pending' + ); + expect(allPending).toBe(true); + }); + + it('filters transactions by type', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + await act(async () => { + result.current.setFilterType('bonus'); + }); + expect(result.current.filterType).toBe('bonus'); + const allBonuses = result.current.filteredTransactions.every( + (tx) => tx.type === 'bonus' + ); + expect(allBonuses).toBe(true); + }); + + it('searches transactions by memo text', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + await act(async () => { + result.current.setSearchQuery('Bonus'); + }); + expect(result.current.searchQuery).toBe('Bonus'); + const matching = result.current.filteredTransactions.every( + (tx) => tx.memo.toLowerCase().includes('bonus') + ); + expect(matching).toBe(true); + }); + + it('resets to page 1 when currency changes', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + await act(async () => { + result.current.setCurrentPage(2); + }); + expect(result.current.currentPage).toBe(2); + + await act(async () => { + result.current.setSelectedCurrency('EUR'); + }); + expect(result.current.currentPage).toBe(1); + expect(result.current.selectedCurrency).toBe('EUR'); + }); + + it('resets to page 1 when filter status changes', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + result.current.setCurrentPage(2); + result.current.setFilterStatus('completed'); + expect(result.current.currentPage).toBe(1); + }); + + it('resets to page 1 when filter type changes', async () => { + const { result } = renderHook(() => useEmployeePortal()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }, { timeout: 10000 }); + + result.current.setCurrentPage(2); + result.current.setFilterType('salary'); + expect(result.current.currentPage).toBe(1); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/useFeeEstimation.test.ts b/frontend/src/__tests__/useFeeEstimation.test.ts new file mode 100644 index 00000000..16b49010 --- /dev/null +++ b/frontend/src/__tests__/useFeeEstimation.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useFeeEstimation } from '../hooks/useFeeEstimation'; +import type { FeeRecommendation, BatchBudgetEstimate, CongestionLevel } from '../services/feeEstimation'; + +const { getFeeRecommendationMock, estimateBatchPaymentBudgetMock } = vi.hoisted(() => ({ + getFeeRecommendationMock: vi.fn(), + estimateBatchPaymentBudgetMock: vi.fn(), +})); + +vi.mock('../services/feeEstimation', () => ({ + getFeeRecommendation: (...args: unknown[]) => getFeeRecommendationMock(...args), + estimateBatchPaymentBudget: (...args: unknown[]) => estimateBatchPaymentBudgetMock(...args), +})); + +const mockRecommendation: FeeRecommendation = { + baseFee: 100, + recommendedFee: 150, + maxFee: 200, + congestionLevel: 'low' as CongestionLevel, + shouldBumpFee: false, + ledgerCapacityUsage: 0.1, + lastLedger: 12345, + recommendedFeeXLM: '0.0000150', + maxFeeXLM: '0.0000200', + baseFeeXLM: '0.0000100', +}; + +const mockBatchEstimate: BatchBudgetEstimate = { + transactionCount: 10, + feePerTransaction: 150, + totalBudget: 1500, + totalBudgetXLM: '0.0001500', + feePerTransactionXLM: '0.0000150', + safetyMargin: 1.0, + congestionLevel: 'low' as CongestionLevel, +}; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +describe('useFeeEstimation', () => { + beforeEach(() => { + vi.clearAllMocks(); + getFeeRecommendationMock.mockResolvedValue(mockRecommendation); + }); + + it('fetches fee recommendation on mount', async () => { + const { result } = renderHook(() => useFeeEstimation(), { wrapper: createWrapper() }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(getFeeRecommendationMock).toHaveBeenCalledOnce(); + expect(result.current.feeRecommendation).toEqual(mockRecommendation); + expect(result.current.isError).toBe(false); + }); + + it('exposes error state when the API call fails', async () => { + getFeeRecommendationMock.mockRejectedValue(new Error('Horizon unreachable')); + + const { result } = renderHook(() => useFeeEstimation(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.feeRecommendation).toBeUndefined(); + expect(result.current.error).toBeInstanceOf(Error); + }); + + it('refetches when refetch is called', async () => { + const { result } = renderHook(() => useFeeEstimation(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(getFeeRecommendationMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.refetch(); + }); + + expect(getFeeRecommendationMock).toHaveBeenCalledTimes(2); + }); + + it('estimates batch budget via estimateBatch', async () => { + estimateBatchPaymentBudgetMock.mockResolvedValue(mockBatchEstimate); + + const { result } = renderHook(() => useFeeEstimation(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + let estimate: BatchBudgetEstimate | undefined; + await act(async () => { + estimate = await result.current.estimateBatch(10); + }); + + expect(estimateBatchPaymentBudgetMock).toHaveBeenCalledWith(10); + expect(estimate).toEqual(mockBatchEstimate); + }); + + it('surfaces error when estimateBatch fails', async () => { + estimateBatchPaymentBudgetMock.mockRejectedValue(new Error('Estimate failed')); + + const { result } = renderHook(() => useFeeEstimation(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await expect(result.current.estimateBatch(5)).rejects.toThrow('Estimate failed'); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/useWallet.test.tsx b/frontend/src/__tests__/useWallet.test.tsx new file mode 100644 index 00000000..053397ca --- /dev/null +++ b/frontend/src/__tests__/useWallet.test.tsx @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act } from '@testing-library/react'; +import React from 'react'; +import { WalletContext, type WalletContextType } from '../hooks/useWallet'; + +describe('useWallet', () => { + const mockContextValue: WalletContextType = { + address: 'GABC123...EXAMPLE', + walletName: 'Freighter', + isConnecting: false, + isInitialized: true, + walletExtensionAvailable: true, + connect: vi.fn(), + disconnect: vi.fn(), + signTransaction: vi.fn().mockResolvedValue('signed-xdr'), + requireWallet: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns wallet context when used within WalletProvider', () => { + let capturedValue: WalletContextType | undefined; + function Consumer() { + const ctx = React.use(WalletContext); + capturedValue = ctx; + return React.createElement('div', { 'data-testid': 'wallet-status' }, + `Address: ${ctx?.address ?? 'none'}` + ); + } + + render( + React.createElement(WalletContext.Provider, { value: mockContextValue }, + React.createElement(Consumer) + ) + ); + + expect(screen.getByTestId('wallet-status')).toHaveTextContent('Address: GABC123...EXAMPLE'); + expect(capturedValue?.walletName).toBe('Freighter'); + expect(capturedValue?.isConnecting).toBe(false); + expect(capturedValue?.isInitialized).toBe(true); + }); + + it('exposes connect function', () => { + function Consumer() { + const ctx = React.use(WalletContext); + return React.createElement('button', { + 'data-testid': 'connect-btn', + onClick: () => ctx?.connect(), + }, 'Connect'); + } + + render( + React.createElement(WalletContext.Provider, { value: mockContextValue }, + React.createElement(Consumer) + ) + ); + + screen.getByTestId('connect-btn').click(); + expect(mockContextValue.connect).toHaveBeenCalledOnce(); + }); + + it('exposes disconnect function', () => { + function Consumer() { + const ctx = React.use(WalletContext); + return React.createElement('button', { + 'data-testid': 'disconnect-btn', + onClick: () => ctx?.disconnect(), + }, 'Disconnect'); + } + + render( + React.createElement(WalletContext.Provider, { value: mockContextValue }, + React.createElement(Consumer) + ) + ); + + screen.getByTestId('disconnect-btn').click(); + expect(mockContextValue.disconnect).toHaveBeenCalledOnce(); + }); + + it('exposes address null when wallet is disconnected', () => { + const disconnectedValue = { ...mockContextValue, address: null, walletName: null }; + + function Consumer() { + const ctx = React.use(WalletContext); + return React.createElement('div', { 'data-testid': 'wallet-status' }, + `Address: ${ctx?.address ?? 'none'}, Wallet: ${ctx?.walletName ?? 'none'}` + ); + } + + render( + React.createElement(WalletContext.Provider, { value: disconnectedValue }, + React.createElement(Consumer) + ) + ); + + expect(screen.getByTestId('wallet-status')).toHaveTextContent('Address: none'); + expect(screen.getByTestId('wallet-status')).toHaveTextContent('Wallet: none'); + }); + + it('exposes isConnecting state during wallet connection', () => { + const connectingValue = { ...mockContextValue, isConnecting: true }; + + function Consumer() { + const ctx = React.use(WalletContext); + return React.createElement('div', { 'data-testid': 'connecting-status' }, + ctx?.isConnecting ? 'Connecting...' : 'Not connecting' + ); + } + + render( + React.createElement(WalletContext.Provider, { value: connectingValue }, + React.createElement(Consumer) + ) + ); + + expect(screen.getByTestId('connecting-status')).toHaveTextContent('Connecting...'); + }); + + it('exposes signTransaction function', async () => { + function Consumer() { + const ctx = React.use(WalletContext); + const [result, setResult] = React.useState(''); + return React.createElement('div', null, + React.createElement('button', { + 'data-testid': 'sign-btn', + onClick: () => { + void (async () => { + const signed = await ctx?.signTransaction('test-xdr'); + setResult(signed ?? ''); + })(); + }, + }, 'Sign'), + React.createElement('div', { 'data-testid': 'signed-result' }, result) + ); + } + + render( + React.createElement(WalletContext.Provider, { value: mockContextValue }, + React.createElement(Consumer) + ) + ); + + await act(async () => { + screen.getByTestId('sign-btn').click(); + }); + await vi.waitFor(() => { + expect(screen.getByTestId('signed-result')).toHaveTextContent('signed-xdr'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/useWithdrawal.test.ts b/frontend/src/__tests__/useWithdrawal.test.ts new file mode 100644 index 00000000..338ad29f --- /dev/null +++ b/frontend/src/__tests__/useWithdrawal.test.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useWithdrawal } from '../hooks/useWithdrawal'; + +// Mock withdrawal service +const { withdrawalServiceMock } = vi.hoisted(() => ({ + withdrawalServiceMock: { + getAvailableAnchors: vi.fn(), + initiateWithdrawal: vi.fn(), + getTransactionStatus: vi.fn(), + cancelWithdrawal: vi.fn(), + }, +})); + +vi.mock('../services/withdrawal', () => ({ + default: withdrawalServiceMock, +})); + +const mockAnchors = [ + { + domain: 'anchor.physics', + name: 'Physics Anchor', + supportedCurrencies: ['XLM', 'NGN', 'USD'], + withdrawMinAmount: 10, + withdrawMaxAmount: 10000, + }, + { + domain: 'company.anchor', + name: 'Company Anchor', + supportedCurrencies: ['XLM', 'NGN'], + }, +]; + +describe('useWithdrawal', () => { + const balance = 5000; + const exchangeRate = 1450; + const selectedCurrency = 'NGN'; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(window, 'open').mockImplementation(() => null); + withdrawalServiceMock.getAvailableAnchors.mockResolvedValue(mockAnchors); + withdrawalServiceMock.initiateWithdrawal.mockResolvedValue({ + transactionId: 'txn-123', + interactiveUrl: 'https://anchor.physics/interactive/abc', + status: 'pending_user_transfer', + }); + withdrawalServiceMock.getTransactionStatus.mockResolvedValue({ + id: 'txn-123', + anchorDomain: 'anchor.physics', + status: 'pending_anchor', + amountIn: 100, + assetCode: 'ORGUSD', + startedAt: new Date().toISOString(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('starts in select_anchor step with empty state', () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + expect(result.current.state.step).toBe('select_anchor'); + expect(result.current.state.anchors).toEqual([]); + expect(result.current.state.selectedAnchor).toBeNull(); + expect(result.current.state.amount).toBe(''); + expect(result.current.state.estimatedReceive).toBe(0); + expect(result.current.state.transaction).toBeNull(); + expect(result.current.state.isLoading).toBe(false); + expect(result.current.state.error).toBeNull(); + }); + + it('loads available anchors', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + await act(async () => { + await result.current.loadAnchors(); + }); + + expect(withdrawalServiceMock.getAvailableAnchors).toHaveBeenCalled(); + expect(result.current.state.anchors).toEqual(mockAnchors); + expect(result.current.state.isLoading).toBe(false); + }); + + it('sets error when anchors fail to load', async () => { + withdrawalServiceMock.getAvailableAnchors.mockRejectedValue( + new Error('Anchor discovery failed') + ); + + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + await act(async () => { + await result.current.loadAnchors(); + }); + + expect(result.current.state.error).toBe('Anchor discovery failed'); + expect(result.current.state.isLoading).toBe(false); + }); + + it('selects an anchor and advances to enter_amount step', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + + expect(result.current.state.selectedAnchor).toEqual(mockAnchors[0]); + expect(result.current.state.step).toBe('enter_amount'); + expect(result.current.state.error).toBeNull(); + }); + + it('calculates estimated receive when amount is set', () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + expect(result.current.state.amount).toBe('100'); + // estimatedReceive = amount * exchangeRate when currency is supported + expect(result.current.state.estimatedReceive).toBe(100 * exchangeRate); + }); + + it('sets error when initiating withdrawal without anchor', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + + expect(result.current.state.error).toBe('Please select an anchor and enter an amount'); + }); + + it('sets error when initiating withdrawal without amount', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + + expect(result.current.state.error).toBe('Please select an anchor and enter an amount'); + }); + + it('rejects invalid amounts', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('0'); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + + expect(result.current.state.error).toBe('Please enter a valid amount'); + }); + + it('rejects amounts exceeding balance', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('6000'); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + + expect(result.current.state.error).toBe('Insufficient balance'); + }); + + it('initiates a withdrawal successfully', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + + expect(withdrawalServiceMock.initiateWithdrawal).toHaveBeenCalledWith({ + anchorDomain: 'anchor.physics', + assetCode: 'ORGUSD', + amount: 100, + destinationType: 'bank_account', + destinationDetails: { accountId: 'acc-1' }, + }); + + expect(result.current.state.step).toBe('processing'); + expect(result.current.state.isLoading).toBe(false); + expect(result.current.state.transaction).not.toBeNull(); + expect(result.current.state.transaction?.status).toBe('pending_user_transfer'); + expect(result.current.state.transaction?.interactiveUrl).toBe( + 'https://anchor.physics/interactive/abc' + ); + }); + + it('handles withdrawal initiation failure', async () => { + withdrawalServiceMock.initiateWithdrawal.mockRejectedValue( + new Error('Anchor rejected withdrawal') + ); + + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + + expect(result.current.state.error).toBe('Anchor rejected withdrawal'); + expect(result.current.state.isLoading).toBe(false); + expect(result.current.state.step).toBe('enter_amount'); + }); + + it('opens the interactive URL in a new window', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + // Initiate successfully so transaction is set + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + expect(result.current.state.transaction).not.toBeNull(); + + act(() => { + result.current.openInteractiveUrl(); + }); + + expect(window.open).toHaveBeenCalledWith( + 'https://anchor.physics/interactive/abc', + '_blank', + 'noopener,noreferrer' + ); + }); + + it('polls transaction status and completes when terminal state reached', async () => { + withdrawalServiceMock.getTransactionStatus.mockResolvedValue({ + id: 'txn-123', + anchorDomain: 'anchor.physics', + status: 'completed', + amountIn: 100, + assetCode: 'ORGUSD', + startedAt: new Date().toISOString(), + completedAt: new Date().toISOString(), + }); + + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + + expect(result.current.state.step).toBe('processing'); + + await act(async () => { + await result.current.pollTransactionStatus(); + }); + + expect(result.current.state.step).toBe('complete'); + expect(result.current.state.transaction?.status).toBe('completed'); + expect(result.current.state.isPolling).toBe(false); + }); + + it('polls transaction status and fails when terminal error state reached', async () => { + withdrawalServiceMock.getTransactionStatus.mockResolvedValue({ + id: 'txn-123', + anchorDomain: 'anchor.physics', + status: 'failed', + amountIn: 100, + assetCode: 'ORGUSD', + startedAt: new Date().toISOString(), + errorMessage: 'Anchor rejected', + }); + + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + + await act(async () => { + await result.current.pollTransactionStatus(); + }); + + expect(result.current.state.step).toBe('failed'); + expect(result.current.state.transaction?.status).toBe('failed'); + }); + + it('handles poll errors', async () => { + withdrawalServiceMock.getTransactionStatus.mockRejectedValue( + new Error('Status check timeout') + ); + + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + await act(async () => { + await result.current.pollTransactionStatus(); + }); + + expect(result.current.state.error).toBe('Status check timeout'); + expect(result.current.state.isPolling).toBe(false); + }); + + it('cancels an active withdrawal and resets state', async () => { + withdrawalServiceMock.cancelWithdrawal.mockResolvedValue({ success: true }); + + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + await act(async () => { + await result.current.initiateWithdrawal('bank_account', { accountId: 'acc-1' }); + }); + expect(result.current.state.step).toBe('processing'); + + await act(async () => { + await result.current.cancelWithdrawal(); + }); + + expect(withdrawalServiceMock.cancelWithdrawal).toHaveBeenCalledWith('txn-123'); + expect(result.current.state.step).toBe('select_anchor'); + expect(result.current.state.transaction).toBeNull(); + expect(result.current.state.selectedAnchor).toBeNull(); + expect(result.current.state.amount).toBe(''); + expect(result.current.state.error).toBeNull(); + }); + + it('resets state back to initial values', async () => { + const { result } = renderHook(() => useWithdrawal(balance, exchangeRate, selectedCurrency)); + + await act(async () => { + await result.current.loadAnchors(); + }); + act(() => { + result.current.selectAnchor(mockAnchors[0]); + }); + act(() => { + result.current.setAmount('100'); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.state.step).toBe('select_anchor'); + expect(result.current.state.anchors).toEqual([]); + expect(result.current.state.selectedAnchor).toBeNull(); + expect(result.current.state.amount).toBe(''); + expect(result.current.state.estimatedReceive).toBe(0); + expect(result.current.state.transaction).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..8b6943ad --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,14 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/__tests__/setup.ts'], + css: false, + exclude: ['node_modules/**', 'e2e/**', 'dist/**', 'src/**/*.e2e.ts'], + }, +});