diff --git a/.gitignore b/.gitignore index aea1036b67..2f734f5f94 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +coverage # Editor directories and files .vscode/* diff --git a/global.css b/global.css deleted file mode 100644 index 3d810a1bd9..0000000000 --- a/global.css +++ /dev/null @@ -1,15 +0,0 @@ -.material-symbols-rounded { - font-family: 'MaterialSymbolsRounded'; - font-weight: normal; - font-style: normal; - font-size: 24px; - line-height: 1; - letter-spacing: normal; - text-transform: none; - display: inline-block; - white-space: nowrap; - word-wrap: normal; - direction: ltr; - -webkit-font-feature-settings: 'liga'; - -webkit-font-smoothing: antialiased; -} diff --git a/index.html b/index.html deleted file mode 100644 index e4b78eae12..0000000000 --- a/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Vite + React + TS - - -
- - - diff --git a/package-lock.json b/package-lock.json index c1aade4607..ddda8d8a62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^3.2.6", "colors": "^1.4.0", "commander": "^14.0.0", "commitizen": "^4.3.1", @@ -133,6 +134,20 @@ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -432,6 +447,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1428,6 +1453,119 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3124,6 +3262,17 @@ "@octokit/openapi-types": "^27.0.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -4528,6 +4677,7 @@ "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" @@ -4547,7 +4697,8 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -4896,15 +5047,50 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.6.tgz", + "integrity": "sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.6", + "vitest": "3.2.6" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "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==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -4913,12 +5099,13 @@ } }, "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==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -4943,15 +5130,17 @@ "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/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", "dev": true, + "license": "MIT", "dependencies": { "tinyrainbow": "^2.0.0" }, @@ -4960,12 +5149,13 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", + "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -4974,12 +5164,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -4988,10 +5179,11 @@ } }, "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==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", "dev": true, + "license": "MIT", "dependencies": { "tinyspy": "^4.0.3" }, @@ -5000,12 +5192,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", + "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, @@ -5444,10 +5637,40 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/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/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", @@ -5772,6 +5995,7 @@ "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", @@ -5826,6 +6050,7 @@ "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" } @@ -6711,6 +6936,7 @@ "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" } @@ -6970,6 +7196,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -8082,6 +8315,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -8397,6 +8647,28 @@ "traverse": "0.6.8" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -8409,6 +8681,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-directory": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", @@ -8731,6 +9029,13 @@ "node": ">=18" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -9569,6 +9874,83 @@ "node": "^18.17 || >=20.6.1" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -9586,6 +9968,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/java-properties": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/java-properties/-/java-properties-1.0.2.tgz", @@ -10380,7 +10778,8 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/lower-case": { "version": "2.0.2", @@ -10417,6 +10816,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, "node_modules/make-asynchronous": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/make-asynchronous/-/make-asynchronous-1.0.1.tgz", @@ -10446,6 +10857,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-eslint-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/markdown-eslint-parser/-/markdown-eslint-parser-1.2.1.tgz", @@ -11183,6 +11623,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -13227,7 +13677,7 @@ } }, "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", + "version": "4.0.3", "dev": true, "inBundle": true, "license": "MIT", @@ -13721,6 +14171,13 @@ "node": ">=4" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -13834,6 +14291,30 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/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/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -13852,6 +14333,7 @@ "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" } @@ -15652,6 +16134,32 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "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/string-width-cjs/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/string-width/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", @@ -15766,6 +16274,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "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-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -15815,6 +16337,7 @@ "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" }, @@ -15826,7 +16349,8 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/stylis": { "version": "4.2.0", @@ -16079,6 +16603,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -16241,6 +16780,7 @@ "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" } @@ -16842,19 +17382,20 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", "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", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -16884,8 +17425,8 @@ "@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", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, @@ -17149,6 +17690,61 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "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/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/package.json b/package.json index 1f786bf421..93bb647a5f 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "preview": "vite preview", "prepare": "husky", "test": "vitest run", + "test:coverage": "vitest run --coverage", "watch": "vitest", "i18n": "node scripts/i18n.js", "logBuildDate": "echo 'Last build: '$(date \"+%c\") | tee ./dist/lastBuild.txt", @@ -89,6 +90,7 @@ "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^3.2.6", "colors": "^1.4.0", "commander": "^14.0.0", "commitizen": "^4.3.1", diff --git a/src/Connect-test.tsx b/src/Connect-test.tsx new file mode 100644 index 0000000000..bb736254e2 --- /dev/null +++ b/src/Connect-test.tsx @@ -0,0 +1,264 @@ +import React from 'react' +import { beforeEach, describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { Connect } from './Connect' +import { initialState, masterData } from 'src/services/mockedData' +import { STEPS } from 'src/const/Connect' + +describe('', () => { + const mockPostMessage = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(window, 'parent', { + writable: true, + configurable: true, + value: { + postMessage: mockPostMessage, + }, + }) + Object.defineProperty(window, 'top', { + writable: true, + configurable: true, + value: {}, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const defaultProps: ConnectProps = { + clientConfig: {} as ClientConfigType, + profiles: { loading: false, ...masterData }, + userFeatures: {}, + experimentalFeatures: {}, + availableAccountTypes: [] as [], + onManualAccountAdded: vi.fn(), + onMemberDeleted: vi.fn(), + onSuccessfulAggregation: vi.fn(), + onUpsertMember: vi.fn(), + onAnalyticEvent: vi.fn(), + onAnalyticPageview: vi.fn(), + onShowConnectSuccessSurvey: () => {}, + onSubmitConnectSuccessSurvey: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('loading states', () => { + it('displays loading spinner when component is loading', () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: true, + }, + } + + render(, { preloadedState }) + + expect(screen.getByText(/Loading/i)).toBeInTheDocument() + }) + + it('renders without crashing when there is a config error', () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + loadError: { + type: 'config', + title: 'Configuration Error', + message: 'This mode is not available for your account', + }, + }, + } + + const { container } = render(, { preloadedState }) + + expect(container).toBeInTheDocument() + }) + + it('renders without crashing when there is a network error', () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + loadError: { + type: 'network', + title: 'Network Error', + message: 'Unable to connect to the server', + }, + }, + } + + const { container } = render(, { preloadedState }) + + expect(container).toBeInTheDocument() + }) + }) + + describe('legacy Atrium API support', () => { + it('sends legacy post message for Atrium with old ui_message_version', async () => { + const preloadedState = { + ...initialState, + profiles: { + ...initialState.profiles, + client: { + ...initialState.profiles.client, + has_atrium_api: true, + }, + }, + config: { + ...initialState.config, + is_mobile_webview: false, + ui_message_version: 3, + }, + connect: { + ...initialState.connect, + isComponentLoading: false, + }, + } + + render(, { preloadedState }) + + await waitFor(() => { + expect(mockPostMessage).toHaveBeenCalled() + const callArgs = mockPostMessage.mock.calls[0] + const messageData = JSON.parse(callArgs[0]) + expect(messageData.type).toBe('mxConnect:widgetLoaded') + }) + }) + }) + + describe('version metadata', () => { + it('stores version prop in redux state', async () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + }, + } + + const { store } = render(, { preloadedState }) + + await waitFor(() => { + expect(store.getState().app.version).toBe('v1.2.3') + }) + }) + + it('handles missing version prop', async () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + }, + } + + const { store } = render(, { preloadedState }) + + await waitFor(() => { + const version = store.getState().app.version + expect(version === null || version === undefined).toBe(true) + }) + }) + }) + + describe('profiles loading', () => { + it('loads profiles on mount', async () => { + const customProfiles = { + loading: false, + ...masterData, + client: { ...masterData.client, name: 'Custom Client Name' }, + } + + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + }, + } + + const { store } = render(, { + preloadedState, + }) + + await waitFor(() => { + expect(store.getState().profiles.client.name).toBe('Custom Client Name') + }) + }) + }) + + describe('renders main connect flow', () => { + it('renders search view when on search step', async () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + location: [{ step: STEPS.SEARCH }], + }, + } + + render(, { preloadedState }) + + await waitFor(() => { + expect(screen.getByTestId('search-input')).toBeInTheDocument() + }) + }) + + it('includes ConnectNavigationHeader in the rendered output', async () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + location: [{ step: STEPS.SEARCH }], + }, + } + + render(, { preloadedState }) + + await waitFor(() => { + expect(document.querySelector('#connect-wrapper')).toBeInTheDocument() + }) + }) + }) + + describe('analytic context provider', () => { + it('provides analytic callbacks to child components', async () => { + const onAnalyticEvent = vi.fn() + const onAnalyticPageview = vi.fn() + const onSubmitConnectSuccessSurvey = vi.fn() + + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + isComponentLoading: false, + location: [{ step: STEPS.SEARCH }], + }, + } + render( + , + { preloadedState }, + ) + + await waitFor(() => { + expect(screen.getByTestId('search-input')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/Connect.tsx b/src/Connect.tsx index f44c31faad..267e07bff1 100644 --- a/src/Connect.tsx +++ b/src/Connect.tsx @@ -328,25 +328,19 @@ export const Connect: React.FC = ({ return (
- {state.memberToDelete && ( - { - setState({ ...state, memberToDelete: null }) - }} - onDeleteSuccess={(deletedMember) => { - postMessageFunctions.onPostMessage('connect/memberDeleted', { - member_guid: deletedMember.guid, - }) - onMemberDeleted(deletedMember.guid) - - setState((prevState) => { - dispatch(connectActions.stepToDeleteMemberSuccess(deletedMember.guid)) - return { ...prevState, memberToDelete: null } - }) - }} - /> - )} + setState({ ...state, memberToDelete: null })} + onMemberDeleted={(memberGuid) => { + postMessageFunctions.onPostMessage('connect/memberDeleted', { + member_guid: memberGuid, + }) + onMemberDeleted(memberGuid) + dispatch(connectActions.stepToDeleteMemberSuccess(memberGuid)) + setState({ ...state, memberToDelete: null }) + }} + /> dispatch(handleGoBackWithSideEffects())} diff --git a/src/components/ConnectInstitutionHeader-test.tsx b/src/components/ConnectInstitutionHeader-test.tsx new file mode 100644 index 0000000000..47e875a3f8 --- /dev/null +++ b/src/components/ConnectInstitutionHeader-test.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { ConnectInstitutionHeader } from 'src/components/ConnectInstitutionHeader' +import { COLOR_SCHEME } from 'src/const/Connect' +import { initialState } from 'src/services/mockedData' + +describe('ConnectInstitutionHeader', () => { + const createPreloadedState = (colorScheme: string) => ({ + ...initialState, + config: { + ...initialState.config, + color_scheme: colorScheme, + }, + }) + + it('renders light backdrop when color scheme is LIGHT', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('backdrop-light')).toBeInTheDocument() + expect(screen.queryByTestId('backdrop-dark')).not.toBeInTheDocument() + }) + + it('renders dark backdrop when color scheme is DARK', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.DARK) + render(, { preloadedState }) + + expect(screen.getByTestId('backdrop-dark')).toBeInTheDocument() + expect(screen.queryByTestId('backdrop-light')).not.toBeInTheDocument() + }) + + it('renders HeaderDevice', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('device-svg')).toBeInTheDocument() + }) + + it('renders InstitutionLogo when institutionGuid is provided', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { + preloadedState, + }) + + expect(screen.getByTestId('institution-logo')).toBeInTheDocument() + expect(screen.queryByTestId('default-institution-icon')).not.toBeInTheDocument() + }) + + it('renders default institution icon when institutionGuid is not provided', () => { + const preloadedState = createPreloadedState(COLOR_SCHEME.LIGHT) + render(, { preloadedState }) + + expect(screen.getByTestId('default-institution-icon')).toBeInTheDocument() + }) +}) diff --git a/src/components/ConnectInstitutionHeader.js b/src/components/ConnectInstitutionHeader.js index 6539ad5098..b2985b40a5 100644 --- a/src/components/ConnectInstitutionHeader.js +++ b/src/components/ConnectInstitutionHeader.js @@ -22,16 +22,25 @@ export const ConnectInstitutionHeader = (props) => { return (
-
- {colorScheme === COLOR_SCHEME.LIGHT ? : } +
+ {colorScheme === COLOR_SCHEME.LIGHT ? ( + + ) : ( + + )}
- +
{props.institutionGuid ? ( - + ) : ( - + )}
diff --git a/src/components/Container-test.tsx b/src/components/Container-test.tsx new file mode 100644 index 0000000000..7e9821df16 --- /dev/null +++ b/src/components/Container-test.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { describe, it, expect } from 'vitest' +import { render } from 'src/utilities/testingLibrary' +import { Container } from 'src/components/Container' +import { STEPS } from 'src/const/Connect' +import { initialState } from 'src/services/mockedData' + +describe('Container', () => { + const preloadedState = initialState + + it('renders', () => { + const { container } = render( + +
Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toBeInTheDocument() + expect(containerDiv).not.toHaveStyle({ maxHeight: '100%' }) + }) + + it('applies maxHeight when step is SEARCH', () => { + const { container } = render( + +
Content
+
, + { preloadedState }, + ) + + const containerDiv = container.querySelector('[data-test="container"]') + expect(containerDiv).toHaveStyle({ maxHeight: '100%' }) + }) +}) diff --git a/src/components/Container.js b/src/components/Container.js deleted file mode 100644 index 51d48c7c1c..0000000000 --- a/src/components/Container.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' - -import { useTokens } from '@kyper/tokenprovider' - -import { STEPS } from 'src/const/Connect' -/** - * Our root container to handle our widgets min/max widths, positioning and padding for all views - */ -export const Container = (props) => { - const tokens = useTokens() - const styles = getStyles(tokens, props.step) - - return ( -
-
{props.children}
-
- ) -} -Container.propTypes = { - step: PropTypes.string, -} - -const getStyles = (tokens, step) => { - return { - container: { - backgroundColor: tokens.BackgroundColor.Container, - minHeight: '100%', - maxHeight: step === STEPS.SEARCH ? '100%' : null, - display: 'flex', - justifyContent: 'center', - }, - content: { - maxWidth: '400px', // Our max content width (does not include side margin) - minWidth: '270px', // Our min content width (does not include side margin) - width: '100%', // We want this container to shrink and grow between our min-max - margin: tokens.Spacing.Large, - }, - } -} diff --git a/src/components/Container.tsx b/src/components/Container.tsx index 7fa7c9f0ae..b44d8766a7 100644 --- a/src/components/Container.tsx +++ b/src/components/Container.tsx @@ -1,9 +1,11 @@ import React from 'react' import { useTokens } from '@kyper/tokenprovider' +import { STEPS } from 'src/const/Connect' interface ContainerProps { children?: React.ReactNode + step?: string } /** @@ -11,7 +13,7 @@ interface ContainerProps { */ export const Container: React.FC = (props) => { const tokens = useTokens() - const styles = getStyles(tokens) + const styles = getStyles(tokens, props.step) return (
@@ -21,11 +23,12 @@ export const Container: React.FC = (props) => { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -const getStyles = (tokens: any) => { +const getStyles = (tokens: any, step?: string) => { return { container: { backgroundColor: tokens.BackgroundColor.Container, minHeight: '100%', + maxHeight: step === STEPS.SEARCH ? '100%' : undefined, display: 'flex', justifyContent: 'center', }, diff --git a/src/components/DeleteMemberSurvey-test.tsx b/src/components/DeleteMemberSurvey-test.tsx new file mode 100644 index 0000000000..8126183264 --- /dev/null +++ b/src/components/DeleteMemberSurvey-test.tsx @@ -0,0 +1,250 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { DeleteMemberSurvey, DELETE_REASONS } from 'src/components/DeleteMemberSurvey' +import { initialState, CONNECTED_MEMBER } from 'src/services/mockedData' +import userEvent from '@testing-library/user-event' +import { apiValue as mockApiValue } from 'src/const/apiProviderMock' +import { ReadableStatuses } from 'src/const/Statuses' + +describe('DeleteMemberSurvey', () => { + const preloadedState = initialState + + it('does not render when isOpen is false', () => { + const { container } = render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + expect(container.firstChild).toBeNull() + }) + + it('renders when isOpen is true', () => { + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + expect(screen.getByText('Disconnect institution')).toBeInTheDocument() + expect(screen.getByTestId('disconnect-disclaimer').textContent).toContain('Chase Bank') + }) + + it('calls onClose when cancel button clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + render( + {}} + />, + { preloadedState }, + ) + + await user.click(screen.getByTestId('disconnect-cancel-button')) + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('shows connected member reasons', () => { + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + expect(screen.getByText(DELETE_REASONS.NO_LONGER_USE_ACCOUNT)).toBeInTheDocument() + expect(screen.getByText(DELETE_REASONS.DONT_WANT_SHARE_DATA)).toBeInTheDocument() + expect(screen.queryByText(DELETE_REASONS.UNABLE_CONNECT_ACCOUNT)).not.toBeInTheDocument() + }) + + it('shows non-connected member reasons', () => { + const nonConnectedMember = { + ...CONNECTED_MEMBER, + connection_status: ReadableStatuses.PREVENTED, + } + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + expect(screen.getByText(DELETE_REASONS.UNABLE_CONNECT_ACCOUNT)).toBeInTheDocument() + expect(screen.getByText(DELETE_REASONS.ACCOUNT_INFORMATION_OLD)).toBeInTheDocument() + expect(screen.queryByText(DELETE_REASONS.NO_LONGER_USE_ACCOUNT)).not.toBeInTheDocument() + }) + + it('shows validation error when no reason selected', async () => { + const user = userEvent.setup() + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByText('Choose a reason for deleting')).toBeInTheDocument() + }) + }) + + it('allows selecting a reason', async () => { + const user = userEvent.setup() + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + + expect(firstReason).toBeChecked() + }) + + it('clears validation error after selecting a reason', async () => { + const user = userEvent.setup() + render( + {}} + onMemberDeleted={() => {}} + />, + { preloadedState }, + ) + + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByText('Choose a reason for deleting')).toBeInTheDocument() + }) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + + expect(screen.queryByText('Choose a reason for deleting')).not.toBeInTheDocument() + }) + + it('successfully deletes member when reason selected', async () => { + const user = userEvent.setup() + const deleteMemberSpy = vi.fn(() => Promise.resolve()) + const onClose = vi.fn() + const onMemberDeleted = vi.fn() + const apiValue = { + ...mockApiValue, + deleteMember: deleteMemberSpy, + } + + render( + , + { apiValue, preloadedState }, + ) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(deleteMemberSpy).toHaveBeenCalledWith(CONNECTED_MEMBER) + }) + + await waitFor(() => { + expect(onMemberDeleted).toHaveBeenCalledWith(CONNECTED_MEMBER.guid) + expect(onClose).toHaveBeenCalled() + }) + }) + + it('shows error message when delete fails', async () => { + const user = userEvent.setup() + const apiValue = { + ...mockApiValue, + deleteMember: vi.fn(() => Promise.reject(new Error('Delete failed'))), + } + + render( + {}} + onMemberDeleted={() => {}} + />, + { apiValue, preloadedState }, + ) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByTestId('disconnect-error-header')).toBeInTheDocument() + }) + + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByTestId('disconnect-error-message')).toBeInTheDocument() + }) + + it('dismisses error dialog when ok clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + const apiValue = { + ...mockApiValue, + deleteMember: vi.fn(() => Promise.reject(new Error('Delete failed'))), + } + + render( + {}} + />, + { apiValue, preloadedState }, + ) + + const firstReason = screen.getAllByRole('radio')[0] + await user.click(firstReason) + await user.click(screen.getByTestId('disconnect-button')) + + await waitFor(() => { + expect(screen.getByTestId('disconnect-error-header')).toBeInTheDocument() + }) + + await user.click(screen.getByTestId('disconnect-ok-button')) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/components/DeleteMemberSurvey.js b/src/components/DeleteMemberSurvey.js index 9d8565541f..6b76b5587f 100644 --- a/src/components/DeleteMemberSurvey.js +++ b/src/components/DeleteMemberSurvey.js @@ -18,8 +18,18 @@ import useAnalyticsPath from 'src/hooks/useAnalyticsPath' import { PageviewInfo } from 'src/const/Analytics' import { ReadableStatuses } from 'src/const/Statuses' +export const DELETE_REASONS = { + NO_LONGER_USE_ACCOUNT: "I no longer use this account or it's not mine", + DONT_WANT_SHARE_DATA: "I don't want to share my data", + ACCOUNT_INFORMATION_OLD: 'The account information is old or inaccurate', + UNABLE_CONNECT_ACCOUNT: 'I am unable to connect this account here', + DONT_WANT_TO_USE_APP: "I don't want to use this app", + DONT_WANT_ACCOUNT_CONNECTED: "I don't want this account connected here", + OTHER_REASON: 'Other', +} + export const DeleteMemberSurvey = (props) => { - const { member, onCancel, onDeleteSuccess } = props + const { isOpen, member, onClose, onMemberDeleted } = props const containerRef = useRef(null) useAnalyticsPath(...PageviewInfo.CONNECT_DELETE_MEMBER_SURVEY) const { api } = useApi() @@ -32,39 +42,34 @@ export const DeleteMemberSurvey = (props) => { const tokens = useTokens() const styles = getStyles(tokens) - const DELETE_REASONS = { - NO_LONGER_USE_ACCOUNT: __("I no longer use this account or it's not mine"), - DONT_WANT_SHARE_DATA: __("I don't want to share my data"), - ACCOUNT_INFORMATION_OLD: __('The account information is old or inaccurate'), - UNABLE_CONNECT_ACCOUNT: __('I am unable to connect this account here'), - DONT_WANT_TO_USE_APP: __("I don't want to use this app"), - DONT_WANT_ACCOUNT_CONNECTED: __("I don't want this account connected here"), - OTHER_REASON: __('Other'), - } - const CONNECTED_REASONS = [ - DELETE_REASONS.NO_LONGER_USE_ACCOUNT, - DELETE_REASONS.DONT_WANT_SHARE_DATA, - DELETE_REASONS.DONT_WANT_TO_USE_APP, - DELETE_REASONS.OTHER_REASON, + __(DELETE_REASONS.NO_LONGER_USE_ACCOUNT), + __(DELETE_REASONS.DONT_WANT_SHARE_DATA), + __(DELETE_REASONS.DONT_WANT_TO_USE_APP), + __(DELETE_REASONS.OTHER_REASON), ] const NON_CONECTED_REASONS = [ - DELETE_REASONS.UNABLE_CONNECT_ACCOUNT, - DELETE_REASONS.ACCOUNT_INFORMATION_OLD, - DELETE_REASONS.DONT_WANT_ACCOUNT_CONNECTED, - DELETE_REASONS.OTHER_REASON, + __(DELETE_REASONS.UNABLE_CONNECT_ACCOUNT), + __(DELETE_REASONS.ACCOUNT_INFORMATION_OLD), + __(DELETE_REASONS.DONT_WANT_ACCOUNT_CONNECTED), + __(DELETE_REASONS.OTHER_REASON), ] useEffect(() => { if (deleteMemberState.loading === false) return () => {} const request$ = defer(() => api.deleteMember(member)).subscribe( - () => onDeleteSuccess(member), + () => { + onMemberDeleted(member.guid) + onClose() + }, (err) => updateDeleteMemberState({ loading: false, error: err }), ) return () => request$.unsubscribe() - }, [deleteMemberState.loading]) + }, [deleteMemberState.loading, api, member, onMemberDeleted, onClose]) + + if (!isOpen || !member) return null let reasonList @@ -109,7 +114,7 @@ export const DeleteMemberSurvey = (props) => {
-
- - - {__('This browser is not supported')} - - - { - // --TR: Full String: "We no longer support Internet Explorer. You can continue, or switch to a supported browser, like Edge, Chrome, or Firefox, for a better experience." - __( - 'We no longer support Internet Explorer. You can continue, or switch to a supported browser, like ', - ) - } - - {__('Edge')} - - {', '} - - {__('Chrome')} - - {', or '} - - {__('Firefox')} - - {', '} - {__(' for a better experience.')} - - - - {__( - 'Clicking the links to supported browsers will take you to an external website with a different privacy policy, security measures, and terms and conditions.', - )} - -
- ) : null -} - -const getStyles = (tokens) => ({ - container: { - background: tokens.BackgroundColor.Modal, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: `0 ${tokens.Spacing.ContainerSidePadding}px`, - position: 'fixed', - top: 0, - left: 0, - right: 0, - bottom: 0, - maxWidth: '352px', // Our max content width (does not include side margin) - minWidth: '270px', // Our min content width (does not include side margin) - margin: '0 auto', - }, - header: { - position: 'absolute', - display: 'flex', - justifyContent: 'flex-end', - width: '100%', - }, - closeButton: { - marginTop: tokens.Spacing.XSmall, - }, - title: { - textAlign: 'center', - marginBottom: tokens.Spacing.Tiny, - }, - paragraph: { - textAlign: 'center', - }, - continueButton: { - marginTop: tokens.Spacing.XLarge, - marginBottom: tokens.Spacing.Medium, - }, - icon: { - marginBottom: tokens.Spacing.Large, - marginTop: tokens.Spacing.Jumbo, - paddingTop: tokens.Spacing.Tiny, - }, -}) - -IEDeprecationDialog.propTypes = { - onAnalyticPageview: PropTypes.func.isRequired, -} diff --git a/src/context/ApiContext-test.tsx b/src/context/ApiContext-test.tsx new file mode 100644 index 0000000000..06a4dba027 --- /dev/null +++ b/src/context/ApiContext-test.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { initialState } from 'src/services/mockedData' +import { ApiProvider, useApi } from 'src/context/ApiContext' +import { CreateMemberForm } from 'src/views/credentials/CreateMemberForm' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' + +describe('ApiContext', () => { + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + current_institution_guid: 'INS-123', + selectedInstitution: { + guid: 'INS-123', + code: 'mxbank', + name: 'MX Bank', + }, + institutions: [ + { + guid: 'INS-123', + code: 'mxbank', + name: 'MX Bank', + }, + ], + }, + } + + const defaultProps = { + onError: () => {}, + onSuccess: () => {}, + } + + it('provides API to child components', async () => { + const mockGetInstitutionCredentials = vi.fn().mockResolvedValue([ + { + guid: 'CRD-1', + label: 'Username', + field_name: 'username', + field_type: 'TEXT', + }, + ]) + + render( + + + , + { preloadedState }, + ) + + await waitFor(() => { + expect(mockGetInstitutionCredentials).toHaveBeenCalledWith('INS-123') + }) + + expect(screen.getByText('Username')).toBeInTheDocument() + }) + + it('allows custom API values to be provided', async () => { + const customGetInstitutionCredentials = vi.fn().mockResolvedValue([ + { + guid: 'CRD-2', + label: 'Password', + field_name: 'password', + field_type: 'PASSWORD', + }, + ]) + + render( + + + , + { preloadedState }, + ) + + await waitFor(() => { + expect(customGetInstitutionCredentials).toHaveBeenCalledWith('INS-123') + }) + + expect(screen.getByText('Password')).toBeInTheDocument() + }) + + it('provides default API values when used outside provider', () => { + const TestComponent = () => { + const { api } = useApi() + return ( +
+
{typeof api.loadMembers === 'function' ? 'yes' : 'no'}
+
+ ) + } + + render(, { preloadedState }) + + expect(screen.getByTestId('has-api')).toHaveTextContent('yes') + }) +}) diff --git a/src/context/ApiContext.tsx b/src/context/ApiContext.tsx index 0be06eccb8..540f8affc0 100644 --- a/src/context/ApiContext.tsx +++ b/src/context/ApiContext.tsx @@ -141,9 +141,6 @@ const ApiProvider = ({ apiValue, children }: ApiProviderTypes) => { const useApi = () => { const context = React.useContext(ApiContext) - if (context === undefined) { - throw new Error('useApi must be used within a ApiProvider') - } return { api: context } } diff --git a/src/context/WebSocketContext-test.tsx b/src/context/WebSocketContext-test.tsx new file mode 100644 index 0000000000..79c791d5fc --- /dev/null +++ b/src/context/WebSocketContext-test.tsx @@ -0,0 +1,74 @@ +import React from 'react' +import { renderHook } from '@testing-library/react' +import { of } from 'rxjs' +import { WebSocketProvider, useWebSocket, WebSocketConnection } from 'src/context/WebSocketContext' + +describe('WebSocketContext', () => { + it('should return undefined when no WebSocket connection is provided', () => { + const { result } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => {children}, + }) + + expect(result.current).toBeUndefined() + }) + + it('should return the WebSocket connection when provided', () => { + const mockConnection: WebSocketConnection = { + isConnected: () => true, + webSocketMessages$: of({ type: 'test' }), + } + + const { result } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current).toBe(mockConnection) + expect(result.current?.isConnected()).toBe(true) + }) + + it('should allow accessing webSocketMessages$ observable', () => { + const mockConnection: WebSocketConnection = { + isConnected: () => false, + webSocketMessages$: of({ event: 'test', payload: { id: 123 } }), + } + + const { result } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result.current?.webSocketMessages$).toBeDefined() + + let receivedMessage: unknown + result.current?.webSocketMessages$.subscribe((msg) => { + receivedMessage = msg + }) + + expect(receivedMessage).toEqual({ event: 'test', payload: { id: 123 } }) + }) + + it('should provide the same connection to multiple consumers', () => { + const mockConnection: WebSocketConnection = { + isConnected: vi.fn(() => true), + webSocketMessages$: of({}), + } + + const { result: result1 } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + const { result: result2 } = renderHook(() => useWebSocket(), { + wrapper: ({ children }) => ( + {children} + ), + }) + + expect(result1.current).toBe(mockConnection) + expect(result2.current).toBe(mockConnection) + }) +}) diff --git a/src/main.tsx b/src/main.tsx deleted file mode 100644 index 0c5139e779..0000000000 --- a/src/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import './global.css' // Import foundational global styles -import './styles.css' // Import more specific global styles or overrides - -import React from 'react' -import { createRoot } from 'react-dom/client' -import ConnectWidget from './ConnectWidget' -import { AGG_MODE } from 'src/const/Connect' - -createRoot(document.getElementById('root') as HTMLElement).render( - , -) diff --git a/src/privacy/withProtection-test.tsx b/src/privacy/withProtection-test.tsx new file mode 100644 index 0000000000..9dd2963d46 --- /dev/null +++ b/src/privacy/withProtection-test.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +import { maskInputFn, withProtection } from 'src/privacy/withProtection' +import { render } from 'src/utilities/testingLibrary' + +describe('maskInputFn', () => { + it('should mask input text by default', () => { + const result = maskInputFn('password123') + expect(result).toBe('***********') + }) + + it('should return original text when element has data-ph-unmask="true"', () => { + const element = document.createElement('input') + element.setAttribute('data-ph-unmask', 'true') + const result = maskInputFn('plainText123', element) + expect(result).toBe('plainText123') + }) +}) + +describe('withProtection', () => { + it('should wrap component with ph-no-capture class by default', () => { + const TestComponent = ({ 'data-test': dataTest }: { 'data-test': string }) => ( +
Sensitive Content
+ ) + const ProtectedComponent = withProtection(TestComponent) + + render() + + const wrapper = document.querySelector('.ph-no-capture') + expect(wrapper).toBeTruthy() + expect(screen.getByTestId('test-component')).toHaveTextContent('Sensitive Content') + }) + + it('should not wrap component when allowCapture is true', () => { + const TestComponent = ({ 'data-test': dataTest }: { 'data-test': string }) => ( +
Public Content
+ ) + const ProtectedComponent = withProtection(TestComponent) + + render() + + const wrapper = document.querySelector('.ph-no-capture') + expect(wrapper).toBeNull() + expect(screen.getByTestId('test-component')).toHaveTextContent('Public Content') + }) + + it('should add data-ph-unmask attribute when allowCapture is true', () => { + const TestComponent = React.forwardRef< + HTMLInputElement, + { 'data-test': string; 'data-ph-unmask'?: boolean } + >((props, ref) => ) + TestComponent.displayName = 'TestComponent' + + const ProtectedComponent = withProtection(TestComponent) + + render() + + const input = screen.getByTestId('test-input') + expect(input.getAttribute('data-ph-unmask')).toBe('true') + }) +}) diff --git a/src/services/mockedData.ts b/src/services/mockedData.ts index a5f2bc3a9b..59b5e86751 100644 --- a/src/services/mockedData.ts +++ b/src/services/mockedData.ts @@ -225,6 +225,20 @@ export const institutionData = { is_disabled_by_client: false, }, } +export const MFA_CREDENTIALS = [ + { + guid: 'CRD-123', + institution_guid: 'INS-123', + external_id: 'UNIQUE_ID_FOR_THIS_CHALLENGE-123', + label: 'What city were you born in?', + field_name: 'What city were you born in?', + field_type: 0, + mfa: true, + status_code: 200, + options: [], + }, +] + export const MFA_MEMBER = { connection_status: 3, guid: 'MBR-123', @@ -235,24 +249,12 @@ export const MFA_MEMBER = { is_managed_by_user: true, is_oauth: false, metadata: null, - mfa: { - credentials: [ - { - guid: 'CRD-123', - institution_guid: 'INS-123', - external_id: 'UNIQUE_ID_FOR_THIS_CHALLENGE-123', - label: 'What city were you born in?', - field_type: 0, - mfa: true, - status_code: 200, - options: [], - }, - ], - }, + mfa: MFA_CREDENTIALS, name: 'Gringotts', user_guid: 'USR-123', verification_is_enabled: true, } + export const NEW_MEMBER = { aggregation_status: null, background_aggregation_is_disabled: false, @@ -304,6 +306,33 @@ export const memberCredentialsData = { }, ], } + +export const CONNECTED_MEMBER = { + guid: 'MBR-123', + name: 'Chase Bank', + connection_status: 6, + aggregation_status: 1, + institution_guid: 'INS-123', + user_guid: 'USR-123', + is_being_aggregated: false, + is_manual: false, + is_managed_by_user: true, + is_oauth: false, +} + +export const NON_CONNECTED_MEMBER = { + guid: 'MBR-456', + name: 'Wells Fargo', + connection_status: 1, + aggregation_status: 0, + institution_guid: 'INS-456', + user_guid: 'USR-123', + is_being_aggregated: false, + is_manual: false, + is_managed_by_user: true, + is_oauth: false, +} + export const CONNECTED_MEMBERS = [ { aggregation_status: 1, diff --git a/src/utilities/Accounts-test.js b/src/utilities/Accounts-test.js new file mode 100644 index 0000000000..fa0a00513c --- /dev/null +++ b/src/utilities/Accounts-test.js @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { getSortedAccountsWithMembers } from 'src/utilities/Accounts' + +describe('getSortedAccountsWithMembers', () => { + it('returns empty array when no accounts match members', () => { + const accounts = [{ guid: 'ACC-1', member_guid: 'MEM-999', user_name: 'Account 1' }] + const members = [{ guid: 'MEM-1', name: 'Member 1' }] + + const result = getSortedAccountsWithMembers(accounts, members) + + expect(result).toEqual([]) + }) + + it('filters accounts by member guid and adds member name', () => { + const accounts = [ + { guid: 'ACC-1', member_guid: 'MEM-1', user_name: 'Checking' }, + { guid: 'ACC-2', member_guid: 'MEM-2', user_name: 'Savings' }, + ] + const members = [ + { guid: 'MEM-1', name: 'Bank of America' }, + { guid: 'MEM-2', name: 'Chase' }, + ] + + const result = getSortedAccountsWithMembers(accounts, members) + + expect(result).toHaveLength(2) + + const checking = result.find((a) => a.guid === 'ACC-1') + const savings = result.find((a) => a.guid === 'ACC-2') + + expect(checking).toEqual({ + guid: 'ACC-1', + member_guid: 'MEM-1', + user_name: 'Checking', + memberName: 'Bank of America', + }) + expect(savings).toEqual({ + guid: 'ACC-2', + member_guid: 'MEM-2', + user_name: 'Savings', + memberName: 'Chase', + }) + }) + + it('sorts accounts by user_name alphabetically', () => { + const accounts = [ + { guid: 'ACC-1', member_guid: 'MEM-1', user_name: 'Savings' }, + { guid: 'ACC-2', member_guid: 'MEM-1', user_name: 'Checking' }, + { guid: 'ACC-3', member_guid: 'MEM-1', user_name: 'Investment' }, + ] + const members = [{ guid: 'MEM-1', name: 'Bank' }] + + const result = getSortedAccountsWithMembers(accounts, members) + + expect(result[0].user_name).toBe('Checking') + expect(result[1].user_name).toBe('Investment') + expect(result[2].user_name).toBe('Savings') + }) + + it('filters out accounts without matching member', () => { + const accounts = [ + { guid: 'ACC-1', member_guid: 'MEM-1', user_name: 'Account 1' }, + { guid: 'ACC-2', member_guid: 'MEM-999', user_name: 'Account 2' }, + { guid: 'ACC-3', member_guid: 'MEM-2', user_name: 'Account 3' }, + ] + const members = [ + { guid: 'MEM-1', name: 'Member 1' }, + { guid: 'MEM-2', name: 'Member 2' }, + ] + + const result = getSortedAccountsWithMembers(accounts, members) + + expect(result).toHaveLength(2) + expect(result.find((a) => a.guid === 'ACC-2')).toBeUndefined() + }) + + it('handles empty members array', () => { + const accounts = [{ guid: 'ACC-1', member_guid: 'MEM-1', user_name: 'Account 1' }] + const members = [] + + const result = getSortedAccountsWithMembers(accounts, members) + + expect(result).toEqual([]) + }) + + it('handles empty accounts array', () => { + const accounts = [] + const members = [{ guid: 'MEM-1', name: 'Member 1' }] + + const result = getSortedAccountsWithMembers(accounts, members) + + expect(result).toEqual([]) + }) + + it('leaves memberName undefined when the matched member has no name', () => { + const accounts = [{ guid: 'ACC-1', member_guid: 'MEM-1', user_name: 'Account 1' }] + const members = [{ guid: 'MEM-1' }] + + const result = getSortedAccountsWithMembers(accounts, members) + + expect(result).toHaveLength(1) + expect(result[0].memberName).toBeUndefined() + }) +}) diff --git a/src/utilities/KeyPress-test.js b/src/utilities/KeyPress-test.js new file mode 100644 index 0000000000..0fddbd8d6f --- /dev/null +++ b/src/utilities/KeyPress-test.js @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import { preventDefaultAndStopAllPropagation } from 'src/utilities/KeyPress' + +describe('preventDefaultAndStopAllPropagation', () => { + const createMockEvent = () => ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + nativeEvent: { + stopImmediatePropagation: vi.fn(), + }, + }) + + it('stops the event on both the synthetic and native event so global listeners do not fire', () => { + const event = createMockEvent() + + preventDefaultAndStopAllPropagation(event) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(event.nativeEvent.stopImmediatePropagation).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/utilities/Polyfill-test.js b/src/utilities/Polyfill-test.js new file mode 100644 index 0000000000..c56d8cf79d --- /dev/null +++ b/src/utilities/Polyfill-test.js @@ -0,0 +1,97 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { fromEntriesPolyfill } from 'src/utilities/Polyfill' + +describe('fromEntriesPolyfill', () => { + let originalFromEntries + + const installPolyfill = () => { + delete Object.fromEntries + fromEntriesPolyfill() + } + + beforeEach(() => { + originalFromEntries = Object.fromEntries + }) + + afterEach(() => { + Object.fromEntries = originalFromEntries + }) + + it('does not override Object.fromEntries if it exists', () => { + const existingImpl = Object.fromEntries + + fromEntriesPolyfill() + + expect(Object.fromEntries).toBe(existingImpl) + }) + + it('adds Object.fromEntries if it does not exist', () => { + installPolyfill() + + expect(Object.fromEntries).toBeDefined() + expect(typeof Object.fromEntries).toBe('function') + }) + + it('creates object from entries array when polyfilled', () => { + installPolyfill() + + const entries = [ + ['a', 1], + ['b', 2], + ['c', 3], + ] + const result = Object.fromEntries(entries) + + expect(result).toEqual({ a: 1, b: 2, c: 3 }) + }) + + it('handles Map entries when polyfilled', () => { + installPolyfill() + + const map = new Map([ + ['key1', 'value1'], + ['key2', 'value2'], + ]) + const result = Object.fromEntries(map) + + expect(result).toEqual({ key1: 'value1', key2: 'value2' }) + }) + + it('throws for non-iterable arguments when polyfilled', () => { + installPolyfill() + + const expectedError = 'Object.fromEntries() requires a single iterable argument' + + expect(() => Object.fromEntries(null)).toThrow(expectedError) + expect(() => Object.fromEntries(42)).toThrow(expectedError) + }) + + it('handles empty entries array when polyfilled', () => { + installPolyfill() + + const result = Object.fromEntries([]) + + expect(result).toEqual({}) + }) + + it('handles various value types when polyfilled', () => { + installPolyfill() + + const entries = [ + ['string', 'value'], + ['number', 42], + ['boolean', true], + ['null', null], + ['object', { nested: 'object' }], + ] + const result = Object.fromEntries(entries) + + expect(result).toEqual({ + string: 'value', + number: 42, + boolean: true, + null: null, + object: { nested: 'object' }, + }) + }) +}) diff --git a/src/utilities/ScrollToTop-test.js b/src/utilities/ScrollToTop-test.js new file mode 100644 index 0000000000..8260206035 --- /dev/null +++ b/src/utilities/ScrollToTop-test.js @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' +import { scrollToTop } from 'src/utilities/ScrollToTop' + +describe('scrollToTop', () => { + it('scrolls the ref element into view and returns the result', () => { + const scrollIntoView = vi.fn().mockReturnValue('scrolled') + const ref = { + current: { + scrollIntoView, + }, + } + + const result = scrollToTop(ref) + + expect(scrollIntoView).toHaveBeenCalledWith(true) + expect(result).toBe('scrolled') + }) + + it('does nothing and returns undefined when ref.current is not set', () => { + expect(scrollToTop({ current: null })).toBeUndefined() + expect(scrollToTop({ current: undefined })).toBeUndefined() + }) +}) diff --git a/src/views/consent/ConsentModal-test.tsx b/src/views/consent/ConsentModal-test.tsx new file mode 100644 index 0000000000..c7d0184a23 --- /dev/null +++ b/src/views/consent/ConsentModal-test.tsx @@ -0,0 +1,117 @@ +import React from 'react' +import { render, screen } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { ConsentModal } from 'src/views/consent/ConsentModal' +import { initialState } from 'src/services/mockedData' +import { STEPS } from 'src/const/Connect' + +const mockInstitution = { + guid: 'INS-123', + name: 'Test Bank', + logo_url: 'https://example.com/logo.png', +} + +const renderConsentStep = () => + render( + {}} + handleCredentialsGoBack={() => {}} + navigationRef={React.createRef()} + onManualAccountAdded={() => {}} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + />, + { + preloadedState: { + ...initialState, + connect: { + ...initialState.connect, + location: [{ step: STEPS.CONSENT }], + selectedInstitution: mockInstitution, + }, + }, + }, + ) + +describe('ConsentModal', () => { + const mockSetDialogIsOpen = vi.fn() + const defaultProps = { + dialogIsOpen: true, + setDialogIsOpen: mockSetDialogIsOpen, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render the modal with its content when dialogIsOpen is true', () => { + render() + + expect(screen.getByText('Who is MX Technologies?')).toBeInTheDocument() + expect( + screen.getByText( + /MX is a trusted financial data platform that securely connects your accounts/i, + ), + ).toBeInTheDocument() + expect(screen.getByText('MX promise:')).toBeInTheDocument() + }) + + it('should render the secure, control, and private promise sections', () => { + render() + + expect(screen.getByText('Secure:')).toBeInTheDocument() + expect( + screen.getByText('Industry-standard encryption protects your data.'), + ).toBeInTheDocument() + expect(screen.getByText('Control:')).toBeInTheDocument() + expect(screen.getByText('You can manage and revoke access anytime.')).toBeInTheDocument() + expect(screen.getByText('Private:')).toBeInTheDocument() + expect( + screen.getByText('Your data is never sold or shared without consent.'), + ).toBeInTheDocument() + }) + + it('should not render the modal when dialogIsOpen is false', () => { + render() + + expect(screen.queryByText('Who is MX Technologies?')).not.toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('should close the modal when the Close button is clicked', async () => { + const { user } = renderConsentStep() + + await user.click(screen.getByTestId('info-button')) + expect(screen.getByText('Who is MX Technologies?')).toBeInTheDocument() + + await user.click(screen.getByText('Close')) + + expect(screen.queryByText('Who is MX Technologies?')).not.toBeInTheDocument() + }) + + it('should close the modal when dismissed with Escape', async () => { + const { user } = renderConsentStep() + + await user.click(screen.getByTestId('info-button')) + expect(screen.getByRole('dialog')).toBeInTheDocument() + + await user.keyboard('{Escape}') + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('should open the MX company page when Learn more is clicked', async () => { + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + const { user } = render() + + await user.click(screen.getByText('Learn more')) + + expect(openSpy).toHaveBeenCalledWith('https://www.mx.com/company/', '_blank') + + openSpy.mockRestore() + }) + }) +}) diff --git a/src/views/consent/DynamicDisclosure-test.tsx b/src/views/consent/DynamicDisclosure-test.tsx new file mode 100644 index 0000000000..0bb2bb3f0a --- /dev/null +++ b/src/views/consent/DynamicDisclosure-test.tsx @@ -0,0 +1,427 @@ +import React from 'react' +import { act, createTestReduxStore, render, screen, waitFor } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import Connect from 'src/Connect' +import { ConnectedTokenProvider } from 'src/ConnectedTokenProvider' +import { PostMessageContext } from 'src/ConnectWidget' +import { ActionTypes } from 'src/redux/actions/Connect' +import { POST_MESSAGES } from 'src/const/postMessages' +import { initialState, institutionData } from 'src/services/mockedData' +import { AGG_MODE, VERIFY_MODE, STEPS } from 'src/const/Connect' +import * as Intl from 'src/utilities/Intl' + +declare global { + interface Window { + app: { + options: { language: string } + } + } +} + +const mockInstitution = { + guid: 'INS-123', + name: 'Test Bank', + logo_url: 'https://example.com/logo.png', +} + +type DynamicDisclosureHandle = { + handleBackButton: () => void + showBackButton: () => boolean +} + +type StateOverrides = { + config?: Record + connect?: Record + profiles?: Record +} + +const renderConsentStep = (stateOverrides: StateOverrides = {}) => { + const navigationRef = React.createRef() + const handleConsentGoBack = vi.fn() + + const preloadedState = { + ...initialState, + config: { ...initialState.config, ...stateOverrides.config }, + profiles: { ...initialState.profiles, ...stateOverrides.profiles }, + connect: { + ...initialState.connect, + ...stateOverrides.connect, + location: [{ step: STEPS.CONSENT }], + selectedInstitution: mockInstitution, + }, + } + + const utils = render( + {}} + navigationRef={navigationRef} + onManualAccountAdded={() => {}} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + />, + { preloadedState }, + ) + + return { ...utils, navigationRef, handleConsentGoBack } +} + +const renderConsentWithNavigation = async (configOverrides: Partial = {}) => { + const store = createTestReduxStore() + const onPostMessage = vi.fn() + + const utils = render( + + + {}} + onSubmitConnectSuccessSurvey={() => {}} + profiles={initialState.profiles as unknown as ProfilesTypes} + userFeatures={{ items: [] }} + /> + + , + { store }, + ) + + await screen.findByTestId('navigation-header') + + act(() => { + store.dispatch({ + type: ActionTypes.SELECT_INSTITUTION_SUCCESS, + payload: { + institution: institutionData.institution, + institutionStatus: null, + consentIsEnabled: true, + additionalProductOption: null, + user: {}, + }, + }) + }) + + await screen.findByTestId('dynamic-disclosure-title') + + return { ...utils, store, onPostMessage } +} + +describe('DynamicDisclosure', () => { + beforeEach(() => { + vi.clearAllMocks() + window.app = { options: { language: 'en-us' } } + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 1000, + }) + Object.defineProperty(document.documentElement, 'scrollTop', { + writable: true, + configurable: true, + value: 0, + }) + Object.defineProperty(document.documentElement, 'clientHeight', { + writable: true, + configurable: true, + value: 800, + }) + }) + + describe('Rendering', () => { + it('renders the consent screen', async () => { + renderConsentStep() + + expect(await screen.findByTestId('dynamic-disclosure-title')).toHaveTextContent( + 'Share your data', + ) + expect(await screen.findByTestId('dynamic-disclosure-p1')).toBeInTheDocument() + expect(await screen.findByText('I consent')).toBeInTheDocument() + expect(await screen.findByText('Account Information')).toBeInTheDocument() + expect(screen.getByText(/Private and secure/i)).toBeInTheDocument() + }) + + it('should render with app name when provided', () => { + const { container } = renderConsentStep({ + profiles: { + client: { ...initialState.profiles.client, oauth_app_name: 'MyApp' }, + }, + }) + + expect(container.textContent).toContain('MyApp uses MX Technologies') + }) + + it('should render without app name when not provided', () => { + const { container } = renderConsentStep({ + profiles: { + client: { ...initialState.profiles.client, oauth_app_name: null }, + }, + }) + + expect(container.textContent).toContain('This app uses MX Technologies') + }) + }) + + describe('Mode-specific rendering', () => { + it('should render AGG mode content when mode is AGG_MODE', () => { + const { container } = renderConsentStep({ config: { mode: AGG_MODE } }) + + expect(container.textContent).toContain('manage your finances') + }) + + it('should render VERIFY mode content when mode is VERIFY_MODE', () => { + const { container } = renderConsentStep({ config: { mode: VERIFY_MODE } }) + + expect(container.textContent).toContain('move money') + }) + + it('should render combined mode content when both AGG and VERIFY', () => { + const { container } = renderConsentStep({ + config: { + mode: AGG_MODE, + data_request: { products: ['transactions', 'identity_verification'] }, + }, + }) + + expect(container.textContent).toContain('move money and manage your finances') + }) + + it('should render AGG mode when include_transactions is true', () => { + const { container } = renderConsentStep({ config: { include_transactions: true } }) + + expect(container.textContent).toContain('manage your finances') + }) + }) + + describe('Modal interaction', () => { + it('loads the consent screen and clicks the info button to open modal', async () => { + const { user } = renderConsentStep() + + await user.click(await screen.findByTestId('info-button')) + + expect(await screen.findByText('Who is MX Technologies?')).toBeInTheDocument() + }) + + it('should toggle modal when info button is clicked multiple times', async () => { + const { user } = renderConsentStep() + + const infoButton = screen.getByTestId('info-button') + await user.click(infoButton) + expect(screen.getByText('Who is MX Technologies?')).toBeInTheDocument() + + const closeButton = screen.getByText('Close') + await user.click(closeButton) + expect(screen.queryByText('Who is MX Technologies?')).not.toBeInTheDocument() + }) + }) + + describe('Consent button', () => { + it('should have consent button disabled initially when not scrolled to bottom', () => { + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 2000, + }) + Object.defineProperty(document.documentElement, 'scrollTop', { + writable: true, + configurable: true, + value: 0, + }) + Object.defineProperty(document.documentElement, 'clientHeight', { + writable: true, + configurable: true, + value: 800, + }) + + renderConsentStep() + + const consentButton = screen.getByTestId('consent-button') + expect(consentButton).toBeDisabled() + }) + + it('should enable consent button when scrolled to bottom', async () => { + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 1000, + }) + Object.defineProperty(document.documentElement, 'scrollTop', { + writable: true, + configurable: true, + value: 200, + }) + Object.defineProperty(document.documentElement, 'clientHeight', { + writable: true, + configurable: true, + value: 800, + }) + + renderConsentStep() + + window.dispatchEvent(new Event('scroll')) + const consentButton = await screen.findByTestId('consent-button') + expect(consentButton).not.toBeDisabled() + }) + + it('should advance to the enter credentials step when consent button is clicked', async () => { + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 1000, + }) + Object.defineProperty(document.documentElement, 'scrollTop', { + writable: true, + configurable: true, + value: 200, + }) + Object.defineProperty(document.documentElement, 'clientHeight', { + writable: true, + configurable: true, + value: 800, + }) + + const { user } = renderConsentStep() + + window.dispatchEvent(new Event('scroll')) + + await waitFor(() => { + const consentButton = screen.getByTestId('consent-button') + expect(consentButton).not.toBeDisabled() + }) + + const consentButton = screen.getByTestId('consent-button') + await user.click(consentButton) + + expect(await screen.findByTestId('credentials-continue')).toBeInTheDocument() + expect(screen.queryByTestId('dynamic-disclosure-title')).not.toBeInTheDocument() + }) + }) + + describe('Translation toggle', () => { + it('should show translation link for Spanish locale', () => { + window.app = { options: { language: 'es' } } + vi.spyOn(Intl, 'getLocale').mockReturnValue('es') + + renderConsentStep() + + expect(screen.getByTestId('translation-button')).toBeInTheDocument() + }) + + it('should show translation link for French-Canadian locale', () => { + window.app = { options: { language: 'fr-ca' } } + vi.spyOn(Intl, 'getLocale').mockReturnValue('fr-ca') + + renderConsentStep() + + expect(screen.getByTestId('translation-button')).toBeInTheDocument() + }) + + it('should not show translation link for English locale', () => { + window.app = { options: { language: 'en-us' } } + vi.spyOn(Intl, 'getLocale').mockReturnValue('en') + + renderConsentStep() + + expect(screen.queryByTestId('translation-button')).not.toBeInTheDocument() + }) + + it('should toggle locale when translation link is clicked', async () => { + window.app = { options: { language: 'es' } } + const setLocaleSpy = vi.spyOn(Intl, 'setLocale') + vi.spyOn(Intl, 'getLocale').mockReturnValue('es') + + const { user } = renderConsentStep() + + const translationButton = screen.getByTestId('translation-button') + await user.click(translationButton) + + expect(setLocaleSpy).toHaveBeenCalledWith('en') + }) + }) + + describe('Imperative handle', () => { + it('navigates back to search when the global back button is clicked', async () => { + const { user, onPostMessage } = await renderConsentWithNavigation() + + await user.click(await screen.findByTestId('back-button')) + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + expect(screen.queryByTestId('dynamic-disclosure-title')).not.toBeInTheDocument() + expect(onPostMessage).toHaveBeenCalledWith(POST_MESSAGES.BACK_TO_SEARCH, {}) + }) + + it('shows the global back button when institution search is enabled', async () => { + await renderConsentWithNavigation({ disable_institution_search: false }) + + expect(await screen.findByTestId('back-button')).toBeInTheDocument() + }) + + it('hides the global back button when institution search is disabled', async () => { + await renderConsentWithNavigation({ disable_institution_search: true }) + + expect(screen.queryByTestId('back-button')).not.toBeInTheDocument() + }) + + it('restores the initial locale when the back button is clicked', async () => { + window.app = { options: { language: 'es' } } + const setLocaleSpy = vi.spyOn(Intl, 'setLocale') + vi.spyOn(Intl, 'getLocale').mockReturnValue('en') + + const { user } = await renderConsentWithNavigation() + + await user.click(await screen.findByTestId('back-button')) + + await waitFor(() => { + expect(setLocaleSpy).toHaveBeenCalledWith('es') + }) + }) + + it('should restore locale when consent button is clicked with non-English initial locale', async () => { + window.app = { options: { language: 'es' } } + const setLocaleSpy = vi.spyOn(Intl, 'setLocale') + vi.spyOn(Intl, 'getLocale').mockReturnValue('en') + + Object.defineProperty(document.documentElement, 'scrollHeight', { + writable: true, + configurable: true, + value: 1000, + }) + Object.defineProperty(document.documentElement, 'scrollTop', { + writable: true, + configurable: true, + value: 200, + }) + Object.defineProperty(document.documentElement, 'clientHeight', { + writable: true, + configurable: true, + value: 800, + }) + + const { user } = renderConsentStep() + + window.dispatchEvent(new Event('scroll')) + + await waitFor(() => { + const consentButton = screen.getByTestId('consent-button') + expect(consentButton).not.toBeDisabled() + }) + + const consentButton = screen.getByTestId('consent-button') + await user.click(consentButton) + + expect(setLocaleSpy).toHaveBeenCalledWith('es') + }) + }) + + describe('Cleanup', () => { + it('should remove scroll event listener on unmount', () => { + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener') + + const { unmount } = renderConsentStep() + + unmount() + + expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) + }) + }) +}) diff --git a/src/views/consent/__tests__/DynamicDisclosure-test.tsx b/src/views/consent/__tests__/DynamicDisclosure-test.tsx deleted file mode 100644 index 5ecba50faa..0000000000 --- a/src/views/consent/__tests__/DynamicDisclosure-test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react' - -import { screen, render } from 'src/utilities/testingLibrary' - -import { DynamicDisclosure } from 'src/views/consent/DynamicDisclosure' - -const onConsentClick = vi.fn() -const onGoBackClick = vi.fn() - -const dynamicDisclosureProps = { - onConsentClick, - onGoBackClick, -} - -describe('dynamic disclosure', () => { - it('loads the consent screen', async () => { - const ref = React.createRef() - render() - - expect(await screen.findByTestId('dynamic-disclosure-title')).toBeInTheDocument() - expect(await screen.findByTestId('dynamic-disclosure-p1')).toBeInTheDocument() - expect(await screen.findByText('I consent')).toBeInTheDocument() - expect(await screen.findByText('Account Information')).toBeInTheDocument() - const buttons = screen.getAllByRole('button') - expect(buttons).toHaveLength(5) - }) - - it('loads the consent screen and clicks the info button to open modal', async () => { - const ref = React.createRef() - const { user } = render() - - await user.click(await screen.findByTestId('info-button')) - - expect(await screen.findByText('Who is MX Technologies?')).toBeInTheDocument() - }) -}) diff --git a/src/views/credentials/CreateMemberForm-test.tsx b/src/views/credentials/CreateMemberForm-test.tsx new file mode 100644 index 0000000000..c3cb85247a --- /dev/null +++ b/src/views/credentials/CreateMemberForm-test.tsx @@ -0,0 +1,292 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestReduxStore, render, screen, waitFor } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { ConnectWidgetWithoutReduxProvider } from 'src/ConnectWidget' +import { initialState, institutionData, masterData, member } from 'src/services/mockedData' +import { apiValue as baseApiValue } from 'src/const/apiProviderMock' +import { PostMessageContext } from 'src/ConnectWidget' +import { STEPS } from 'src/const/Connect' +import { ReadableStatuses } from 'src/const/Statuses' + +type RenderCredentialsStepOptions = { + apiOverrides?: Partial + members?: unknown[] + onUpsertMember?: ReturnType +} + +const renderCredentialsStep = ({ + apiOverrides = {}, + members = [], + onUpsertMember = vi.fn(), +}: RenderCredentialsStepOptions = {}) => { + const onPostMessage = vi.fn() + const navigationRef = React.createRef() + + const mockApi = { + ...baseApiValue, + addMember: vi.fn(baseApiValue.addMember), + getInstitutionCredentials: vi.fn(baseApiValue.getInstitutionCredentials), + loadMemberByGuid: vi.fn(baseApiValue.loadMemberByGuid), + updateMember: vi.fn(baseApiValue.updateMember), + ...apiOverrides, + } + + const preloadedState = { + ...initialState, + profiles: { + ...initialState.profiles, + clientProfile: { ...initialState.profiles.clientProfile, uses_oauth: false }, + }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.ENTER_CREDENTIALS }], + selectedInstitution: institutionData.institution, + members, + }, + app: { humanEvent: true }, + } as unknown as typeof initialState + + return { + ...render( + + {}} + handleCredentialsGoBack={() => {}} + navigationRef={navigationRef} + onManualAccountAdded={() => {}} + onUpsertMember={onUpsertMember} + setConnectLocalState={() => {}} + /> + , + { + apiValue: mockApi, + preloadedState, + }, + ), + mockApi, + onPostMessage, + onUpsertMember, + navigationRef, + } +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Loading State', () => { + it('does not render the credentials form while fetching credentials', () => { + renderCredentialsStep({ + apiOverrides: { + getInstitutionCredentials: vi.fn().mockImplementation(() => new Promise(() => {})), + }, + }) + + expect(screen.queryByText('Continue')).not.toBeInTheDocument() + }) + }) + + describe('Credentials Display', () => { + it('renders the credentials form with institution header after loading', async () => { + renderCredentialsStep() + + expect(await screen.findByText('Continue')).toBeInTheDocument() + expect(screen.getByLabelText('Username *')).toBeInTheDocument() + expect(screen.getByLabelText('Password *')).toBeInTheDocument() + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('handles error when fetching credentials fails', async () => { + const error = new Error('Failed to fetch credentials') + renderCredentialsStep({ + apiOverrides: { + getInstitutionCredentials: vi.fn().mockRejectedValue(error), + }, + }) + + await waitFor(() => { + expect(screen.queryByText('Continue')).not.toBeInTheDocument() + }) + + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + }) + + describe('Member Creation', () => { + it('submits credentials and creates a member with the institution data', async () => { + const { mockApi, onPostMessage, user } = renderCredentialsStep() + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.addMember).toHaveBeenCalledWith( + expect.objectContaining({ + institution_guid: institutionData.institution.guid, + rawInstitutionData: institutionData.institution, + }), + expect.any(Object), + true, + ) + }) + + expect(onPostMessage).toHaveBeenCalledWith('connect/enterCredentials', { + institution: { + guid: institutionData.institution.guid, + code: institutionData.institution.code, + }, + }) + }) + + it('calls the consumer onUpsertMember callback when a member is created', async () => { + const onUpsertMember = vi.fn() + + const { user } = render( + , + { apiValue: baseApiValue, store: createTestReduxStore() }, + ) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(onUpsertMember).toHaveBeenCalledWith(member.member) + }) + }) + }) + + describe('409 Conflict Handling', () => { + it('handles 409 error when member already exists and is challenged', async () => { + const existingMemberGuid = 'MBR-EXISTING' + const challengedMember = { + guid: existingMemberGuid, + connection_status: ReadableStatuses.CHALLENGED, + } + + const { mockApi, user } = renderCredentialsStep({ + members: [challengedMember], + apiOverrides: { + addMember: vi.fn().mockRejectedValue({ + response: { + status: 409, + data: { guid: existingMemberGuid }, + }, + }), + loadMemberByGuid: vi.fn().mockResolvedValue(challengedMember), + }, + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.loadMemberByGuid).toHaveBeenCalledWith(existingMemberGuid, 'en') + }) + }) + + it('updates the existing member and calls onUpsertMember on a 409 conflict', async () => { + const existingMemberGuid = 'MBR-EXISTING' + const existingMember = { + guid: existingMemberGuid, + connection_status: ReadableStatuses.CONNECTED, + use_cases: ['verification'], + } + const updatedMember = { + ...existingMember, + connection_status: ReadableStatuses.CONNECTED, + } + + const { mockApi, onUpsertMember, user } = renderCredentialsStep({ + members: [existingMember], + apiOverrides: { + addMember: vi.fn().mockRejectedValue({ + response: { + status: 409, + data: { guid: existingMemberGuid }, + }, + }), + loadMemberByGuid: vi.fn().mockResolvedValue(existingMember), + updateMember: vi.fn().mockResolvedValue(updatedMember), + }, + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(onUpsertMember).toHaveBeenCalledWith(updatedMember) + }) + expect(mockApi.updateMember).toHaveBeenCalled() + }) + }) + + describe('Integration', () => { + it('disables the continue button while the member creation is in flight', async () => { + const { user } = renderCredentialsStep({ + apiOverrides: { + addMember: vi.fn().mockImplementation(() => new Promise(() => {})), // Never resolves + }, + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + + const button = screen.getByTestId('credentials-continue') + expect(button).not.toBeDisabled() + + await user.click(button) + + await waitFor( + () => { + const processingButton = screen.getByTestId('credentials-continue') + expect(processingButton).toBeDisabled() + }, + { timeout: 2000 }, + ) + }) + + it('displays error in Credentials when member creation fails', async () => { + const errorResponse = { + response: { + status: 500, + data: { message: 'Server error' }, + }, + } + const { user } = renderCredentialsStep({ + apiOverrides: { + addMember: vi.fn().mockRejectedValue(errorResponse), + }, + }) + + await user.type(await screen.findByLabelText('Username *'), 'testuser') + await user.type(await screen.findByLabelText('Password *'), 'testpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + }) + }) +}) diff --git a/src/views/credentials/UpdateMemberForm-test.tsx b/src/views/credentials/UpdateMemberForm-test.tsx new file mode 100644 index 0000000000..959e63bd79 --- /dev/null +++ b/src/views/credentials/UpdateMemberForm-test.tsx @@ -0,0 +1,225 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestReduxStore, render, screen, waitFor } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { ConnectWidgetWithoutReduxProvider } from 'src/ConnectWidget' +import { initialState, institutionData, masterData, member } from 'src/services/mockedData' +import { apiValue as baseApiValue } from 'src/const/apiProviderMock' +import { PostMessageContext } from 'src/ConnectWidget' +import { STEPS } from 'src/const/Connect' + +type RenderUpdateStepOptions = { + apiOverrides?: Partial + onUpsertMember?: ReturnType +} + +const renderUpdateStep = ({ + apiOverrides = {}, + onUpsertMember = vi.fn(), +}: RenderUpdateStepOptions = {}) => { + const onPostMessage = vi.fn() + const navigationRef = React.createRef() + + const mockApi = { + ...baseApiValue, + getMemberCredentials: vi.fn(baseApiValue.getMemberCredentials), + updateMember: vi.fn(baseApiValue.updateMember), + ...apiOverrides, + } + + const preloadedState = { + ...initialState, + profiles: { + ...initialState.profiles, + clientProfile: { ...initialState.profiles.clientProfile, uses_oauth: false }, + }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.ENTER_CREDENTIALS }], + selectedInstitution: institutionData.institution, + currentMemberGuid: member.member.guid, + members: [member.member], + updateCredentials: true, + }, + app: { humanEvent: true }, + } as unknown as typeof initialState + + return { + ...render( + + {}} + handleCredentialsGoBack={() => {}} + navigationRef={navigationRef} + onManualAccountAdded={() => {}} + onUpsertMember={onUpsertMember} + setConnectLocalState={() => {}} + /> + , + { + apiValue: mockApi, + preloadedState, + }, + ), + mockApi, + onPostMessage, + onUpsertMember, + navigationRef, + } +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Loading State', () => { + it('does not render the credentials form while fetching credentials', () => { + renderUpdateStep({ + apiOverrides: { + getMemberCredentials: vi.fn().mockImplementation(() => new Promise(() => {})), + }, + }) + + expect(screen.queryByText('Continue')).not.toBeInTheDocument() + }) + }) + + describe('Credentials Display', () => { + it('renders the credentials form with institution header after loading', async () => { + renderUpdateStep() + + expect(await screen.findByText('Continue')).toBeInTheDocument() + expect(screen.getByLabelText('Username *')).toBeInTheDocument() + expect(screen.getByLabelText('Password *')).toBeInTheDocument() + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('handles error when fetching credentials fails', async () => { + const error = new Error('Failed to fetch credentials') + renderUpdateStep({ + apiOverrides: { + getMemberCredentials: vi.fn().mockRejectedValue(error), + }, + }) + + await waitFor(() => { + expect(screen.queryByText('Continue')).not.toBeInTheDocument() + }) + + expect(screen.getByTestId('institution-block')).toBeInTheDocument() + }) + }) + + describe('Member Update', () => { + it('submits credentials and updates the member with the member data', async () => { + const { mockApi, onPostMessage, user } = renderUpdateStep() + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(mockApi.updateMember).toHaveBeenCalledWith( + expect.objectContaining({ + guid: member.member.guid, + }), + expect.any(Object), + true, + ) + }) + + expect(onPostMessage).toHaveBeenCalledWith('connect/updateCredentials', { + institution: { + guid: institutionData.institution.guid, + code: institutionData.institution.code, + }, + member_guid: member.member.guid, + }) + }) + + it('calls the consumer onUpsertMember callback when a member is updated', async () => { + const onUpsertMember = vi.fn() + + const { user } = render( + , + { apiValue: baseApiValue, store: createTestReduxStore() }, + ) + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(onUpsertMember).toHaveBeenCalledWith(member.member) + }) + }) + }) + + describe('Error in Update', () => { + it('displays error in Credentials when member update fails', async () => { + const errorResponse = { + response: { + status: 500, + data: { message: 'Server error' }, + }, + } + const { user } = renderUpdateStep({ + apiOverrides: { + updateMember: vi.fn().mockRejectedValue(errorResponse), + }, + }) + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + await user.click(screen.getByText('Continue')) + + await waitFor(() => { + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + }) + }) + + describe('Integration', () => { + it('disables the continue button while the member update is in flight', async () => { + const { user } = renderUpdateStep({ + apiOverrides: { + updateMember: vi.fn().mockImplementation(() => new Promise(() => {})), + }, + }) + + await user.type(await screen.findByLabelText('Username *'), 'newuser') + await user.type(await screen.findByLabelText('Password *'), 'newpass') + + const button = screen.getByTestId('credentials-continue') + expect(button).not.toBeDisabled() + + await user.click(button) + + await waitFor( + () => { + const processingButton = screen.getByTestId('credentials-continue') + expect(processingButton).toBeDisabled() + }, + { timeout: 2000 }, + ) + }) + }) +}) diff --git a/src/views/deleteMemberSuccess/DeleteMemberSuccess-test.tsx b/src/views/deleteMemberSuccess/DeleteMemberSuccess-test.tsx new file mode 100644 index 0000000000..69103832d5 --- /dev/null +++ b/src/views/deleteMemberSuccess/DeleteMemberSuccess-test.tsx @@ -0,0 +1,78 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { initialState, institutionData } from 'src/services/mockedData' +import { PostMessageContext } from 'src/ConnectWidget' +import { STEPS } from 'src/const/Connect' + +type RenderDeleteMemberSuccessStepOptions = { + institution?: typeof institutionData.institution +} + +const renderDeleteMemberSuccessStep = ({ + institution = institutionData.institution, +}: RenderDeleteMemberSuccessStepOptions = {}) => { + const onPostMessage = vi.fn() + + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + location: [{ step: STEPS.DELETE_MEMBER_SUCCESS }], + selectedInstitution: institution, + }, + } as unknown as typeof initialState + + return { + ...render( + + {}} + handleCredentialsGoBack={() => {}} + navigationRef={React.createRef()} + onManualAccountAdded={() => {}} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + /> + , + { preloadedState }, + ), + onPostMessage, + } +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Content Display', () => { + it('renders the success content with the institution name', () => { + renderDeleteMemberSuccessStep({ + institution: { ...institutionData.institution, name: 'Custom Bank' }, + }) + + expect(screen.getByTestId('disconnected-primary-text')).toHaveTextContent('Disconnected') + expect(screen.getByTestId('disconnected-secondary-text')).toHaveTextContent( + 'You have successfully disconnected Custom Bank.', + ) + expect(screen.getByTestId('done-button')).toHaveTextContent('Done') + expect(screen.getByText('Private and secure')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('posts back to search and returns to the search step when Done is clicked', async () => { + const { onPostMessage, user } = renderDeleteMemberSuccessStep() + + await user.click(screen.getByTestId('done-button')) + + expect(onPostMessage).toHaveBeenCalledWith('connect/backToSearch') + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + expect(screen.queryByTestId('disconnected-primary-text')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/views/disclosure/Disclosure-test.tsx b/src/views/disclosure/Disclosure-test.tsx new file mode 100644 index 0000000000..f05d8738bb --- /dev/null +++ b/src/views/disclosure/Disclosure-test.tsx @@ -0,0 +1,208 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { Disclosure } from 'src/views/disclosure/Disclosure' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { initialState, institutionData } from 'src/services/mockedData' +import { STEPS } from 'src/const/Connect' + +type DisclosureHandle = { + handleBackButton: () => void + showBackButton: () => boolean +} + +const preloadedState = { + ...initialState, + browser: { + height: 0, + isMobile: false, + isTablet: false, + size: '', + width: 0, + }, + connect: { + ...initialState.connect, + selectedInstitution: institutionData.institution, + }, +} + +const stateWithExternalLinkPopup = { + ...preloadedState, + profiles: { + ...preloadedState.profiles, + clientProfile: { + ...preloadedState.profiles.clientProfile, + show_external_link_popup: true, + }, + }, +} + +describe('', () => { + let openSpy: MockInstance + + beforeEach(() => { + vi.clearAllMocks() + openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + Element.prototype.scrollIntoView = vi.fn() + }) + + afterEach(() => { + openSpy.mockRestore() + }) + + describe('Content Display', () => { + it('renders the disclosure screen content', () => { + render(, { preloadedState }) + + expect(screen.getByTestId('disclosure-svg-header')).toBeInTheDocument() + expect(screen.getByTestId('disclosure-title')).toHaveTextContent('Connect your account') + expect(screen.getByTestId('disclosure-paragraph1')).toHaveTextContent( + 'This app will have access to the information below unless you choose to disconnect:', + ) + expect(screen.getByTestId('disclosure-paragraph-2')).toHaveTextContent( + 'Your information is protected with bank-level security.', + ) + expect(screen.getByTestId('disclosure-privacy-policy-text')).toHaveTextContent( + 'By clicking Continue, you agree to the', + ) + expect(screen.getByTestId('disclosure-privacy-policy-link')).toHaveTextContent( + 'MX Privacy Policy.', + ) + expect(screen.getByTestId('disclosure-continue')).toHaveTextContent('Continue') + expect(screen.getByTestId('disclosure-databymx')).toBeInTheDocument() + }) + }) + + describe('Mode-specific Content', () => { + it.each([ + { + mode: 'aggregation', + item1: { testId: 'disclosure-agg-mode-list-item1', text: 'Account details' }, + item2: { + testId: 'disclosure-agg-mode-list-item2', + text: 'Account balances and transactions', + }, + }, + { + mode: 'verification', + item1: { testId: 'disclosure-ver-mode-list-item1', text: 'Routing and account numbers' }, + item2: { testId: 'disclosure-ver-mode-list-item2', text: 'Account balances' }, + }, + { + mode: 'tax', + item1: { testId: 'disclosure-tax-mode-list-item1', text: 'Basic account information' }, + item2: { testId: 'disclosure-tax-mode-list-item2', text: 'Tax documents' }, + }, + ])('renders $mode mode list items', ({ mode, item1, item2 }) => { + render(, { + preloadedState: { ...preloadedState, config: { ...preloadedState.config, mode } }, + }) + + expect(screen.getByTestId(item1.testId)).toHaveTextContent(item1.text) + expect(screen.getByTestId(item2.testId)).toHaveTextContent(item2.text) + }) + }) + + describe('Privacy Policy Link', () => { + it('opens privacy policy externally when show_external_link_popup is false', async () => { + const stateWithoutPopup = { + ...preloadedState, + profiles: { + ...preloadedState.profiles, + clientProfile: { + ...preloadedState.profiles.clientProfile, + show_external_link_popup: false, + }, + }, + } + + const { user } = render(, { preloadedState: stateWithoutPopup }) + + await user.click(screen.getByTestId('disclosure-privacy-policy-link')) + + expect(openSpy).toHaveBeenCalledWith( + 'https://www.mx.com/privacy/', + '_blank', + 'noopener,noreferrer', + ) + }) + + it('shows inline privacy policy when show_external_link_popup is true', async () => { + const { user } = render( +
+ +
, + { preloadedState: stateWithExternalLinkPopup }, + ) + + await user.click(screen.getByTestId('disclosure-privacy-policy-link')) + + expect(await screen.findByTestId('leaving-notice-flat-header')).toBeInTheDocument() + + expect(openSpy).not.toHaveBeenCalled() + }) + }) + + describe('Continue Button', () => { + it('advances to the search step when Continue is clicked', async () => { + const { user } = render( + {}} + handleCredentialsGoBack={() => {}} + navigationRef={React.createRef()} + onManualAccountAdded={() => {}} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + />, + { + preloadedState: { + ...preloadedState, + connect: { + ...preloadedState.connect, + location: [{ step: STEPS.DISCLOSURE }], + }, + }, + }, + ) + + const continueButton = screen.getByTestId('disclosure-continue') + expect(continueButton).toBeEnabled() + + await user.click(continueButton) + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + expect(screen.queryByTestId('disclosure-title')).not.toBeInTheDocument() + }) + }) + + describe('Imperative Handle', () => { + it('toggles the privacy policy and back button via the imperative handle', async () => { + const ref = React.createRef() + + const { user } = render( +
+ +
, + { preloadedState: stateWithExternalLinkPopup }, + ) + + expect(ref.current?.showBackButton()).toBe(false) + + await user.click(screen.getByTestId('disclosure-privacy-policy-link')) + + expect(await screen.findByTestId('leaving-notice-flat-header')).toBeInTheDocument() + expect(ref.current?.showBackButton()).toBe(true) + + await waitFor(() => { + ref.current?.handleBackButton() + }) + + await waitFor(() => { + expect(screen.queryByTestId('leaving-notice-flat-header')).not.toBeInTheDocument() + expect(screen.getByTestId('disclosure-title')).toBeInTheDocument() + expect(ref.current?.showBackButton()).toBe(false) + }) + }) + }) +}) diff --git a/src/views/disclosure/Disclosure.js b/src/views/disclosure/Disclosure.js index 2030ccf153..ce68617efe 100644 --- a/src/views/disclosure/Disclosure.js +++ b/src/views/disclosure/Disclosure.js @@ -29,6 +29,7 @@ import { goToUrlLink } from 'src/utilities/global' export const Disclosure = React.forwardRef((_, disclosureRef) => { const containerRef = useRef(null) useAnalyticsPath(...PageviewInfo.CONNECT_DISCLOSURE) + const size = useSelector(getSize) const isSmall = size === 'small' const tokens = useTokens() const styles = getStyles(tokens, isSmall) @@ -37,7 +38,6 @@ export const Disclosure = React.forwardRef((_, disclosureRef) => { // Redux const { isInAggMode, isInTaxMode, isInVerifyMode } = useSelector(selectCurrentMode) const connectConfig = useSelector(selectConnectConfig) - const size = useSelector(getSize) const showExternalLinkPopup = useSelector( (state) => state.profiles.clientProfile.show_external_link_popup, ) diff --git a/src/views/loginError/ImpededMemberError-test.tsx b/src/views/loginError/ImpededMemberError-test.tsx new file mode 100644 index 0000000000..0728a8f121 --- /dev/null +++ b/src/views/loginError/ImpededMemberError-test.tsx @@ -0,0 +1,139 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { LoginError as LoginErrorComponent } from 'src/views/loginError/LoginError' +import { initialState, institutionData } from 'src/services/mockedData' +import { ReadableStatuses } from 'src/const/Statuses' +import { STEPS } from 'src/const/Connect' + +const LoginError = LoginErrorComponent as unknown as React.ComponentType> + +describe('', () => { + const impededMember = { + guid: 'MBR-123', + error: {}, + name: 'Test Member', + connection_status: ReadableStatuses.IMPEDED, + } + + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + selectedInstitution: institutionData.institution, + currentMemberGuid: impededMember.guid, + members: [impededMember], + location: [{ step: 'LOGIN_ERROR' }], + }, + } + + const defaultProps = { + institution: institutionData.institution, + isDeleteInstitutionOptionEnabled: true, + member: impededMember, + onDeleteConnectionClick: vi.fn(), + onRefreshClick: vi.fn(), + onUpdateCredentialsClick: vi.fn(), + showExternalLinkPopup: false, + showSupport: true, + size: 'medium', + } + + let openSpy: MockInstance + + beforeEach(() => { + vi.clearAllMocks() + openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + }) + + afterEach(() => { + openSpy.mockRestore() + }) + + const renderImpededMemberError = (props: Partial = {}) => + render( +
+ +
, + { preloadedState }, + ) + + const renderStepProps = { + availableAccountTypes: [], + handleConsentGoBack: vi.fn(), + handleCredentialsGoBack: vi.fn(), + navigationRef: React.createRef(), + onManualAccountAdded: vi.fn(), + onUpsertMember: vi.fn(), + setConnectLocalState: vi.fn(), + } + + const renderImpededErrorStep = () => + render(, { + preloadedState: { + ...preloadedState, + connect: { + ...preloadedState.connect, + location: [{ step: STEPS.ACTIONABLE_ERROR }], + }, + }, + }) + + describe('Content Display', () => { + it('renders the impeded error with both resolution steps', () => { + renderImpededMemberError() + + expect(screen.getByText('Your attention is needed')).toBeInTheDocument() + expect( + screen.getByText( + 'Your login info was correct, but your attention is needed at Test Bank before we can proceed. You need to:', + ), + ).toBeInTheDocument() + + expect(screen.getByText('1')).toBeInTheDocument() + expect( + screen.getByText("Log in to Test Bank's website and resolve the issue."), + ).toBeInTheDocument() + expect(screen.getByText('Visit website')).toBeInTheDocument() + + expect(screen.getByText('2')).toBeInTheDocument() + expect( + screen.getByText('Come back here and try to connect your account again.'), + ).toBeInTheDocument() + expect(screen.getByText('Try again')).toBeInTheDocument() + }) + }) + + describe('Try Again Link', () => { + it('leaves the error view and returns to connecting when clicked', async () => { + const { user } = renderImpededErrorStep() + + expect(await screen.findByText('Your attention is needed')).toBeInTheDocument() + + await user.click(screen.getByText('Try again')) + + expect(screen.queryByText('Your attention is needed')).not.toBeInTheDocument() + }) + }) + + describe('Visit Website Link', () => { + it('opens the institution login URL in a new tab when external link popup is disabled', async () => { + const { user } = renderImpededMemberError({ showExternalLinkPopup: false }) + + await user.click(screen.getByText('Visit website')) + + expect(openSpy).toHaveBeenCalledWith('https://test.com', '_blank') + expect(screen.queryByTestId('leaving-notice-flat-header')).not.toBeInTheDocument() + }) + + it('shows the leaving notice instead of navigating when external link popup is enabled', async () => { + const { user } = renderImpededMemberError({ showExternalLinkPopup: true }) + + await user.click(screen.getByText('Visit website')) + + expect(screen.getByTestId('leaving-notice-flat-header')).toBeInTheDocument() + expect(openSpy).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/views/loginError/LeavingAction-test.tsx b/src/views/loginError/LeavingAction-test.tsx new file mode 100644 index 0000000000..2f10b9d193 --- /dev/null +++ b/src/views/loginError/LeavingAction-test.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { initialState, institutionData } from 'src/services/mockedData' +import { ReadableStatuses } from 'src/const/Statuses' +import { STEPS } from 'src/const/Connect' + +describe('', () => { + const impededMember = { + guid: 'MBR-123', + error: {}, + name: 'Test Member', + connection_status: ReadableStatuses.IMPEDED, + } + + const preloadedState = { + ...initialState, + connect: { + ...initialState.connect, + location: [{ step: STEPS.ACTIONABLE_ERROR }], + selectedInstitution: institutionData.institution, + currentMemberGuid: impededMember.guid, + members: [impededMember], + }, + } + + const renderStepProps = { + availableAccountTypes: [], + handleConsentGoBack: vi.fn(), + handleCredentialsGoBack: vi.fn(), + navigationRef: React.createRef(), + onManualAccountAdded: vi.fn(), + onUpsertMember: vi.fn(), + setConnectLocalState: vi.fn(), + } + + let openSpy: MockInstance + + beforeEach(() => { + vi.clearAllMocks() + openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + }) + + afterEach(() => { + openSpy.mockRestore() + }) + + const renderLeavingNotice = async () => { + const view = render( +
+ +
, + { preloadedState }, + ) + + await view.user.click(screen.getByText('Visit website')) + + return view + } + + describe('Content Display', () => { + it('renders the leaving notice with continue and cancel actions', async () => { + await renderLeavingNotice() + + expect(screen.getByTestId('leaving-notice-flat-header')).toBeInTheDocument() + expect(screen.getByTestId('leaving-notice-flat-continue-button')).toBeInTheDocument() + expect(screen.getByTestId('leaving-notice-flat-cancel-button')).toBeInTheDocument() + }) + }) + + describe('Cancel Button', () => { + it('dismisses the leaving notice and returns to the error without navigating', async () => { + const { user } = await renderLeavingNotice() + + await user.click(screen.getByTestId('leaving-notice-flat-cancel-button')) + + expect(await screen.findByText('Your attention is needed')).toBeInTheDocument() + expect(screen.queryByTestId('leaving-notice-flat-header')).not.toBeInTheDocument() + expect(openSpy).not.toHaveBeenCalled() + }) + }) + + describe('Continue Button', () => { + it("opens the institution's login page in a new tab", async () => { + const { user } = await renderLeavingNotice() + + await user.click(screen.getByTestId('leaving-notice-flat-continue-button')) + + expect(openSpy).toHaveBeenCalledWith('https://test.com', '_blank') + }) + }) +}) diff --git a/src/views/loginError/LoginError-test.jsx b/src/views/loginError/LoginError-test.jsx new file mode 100644 index 0000000000..d97e647b36 --- /dev/null +++ b/src/views/loginError/LoginError-test.jsx @@ -0,0 +1,244 @@ +import React from 'react' +import { act, render, screen } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { LoginError } from 'src/views/loginError/LoginError' +import { initialState as defaultState } from 'src/services/mockedData' +import { ReadableStatuses } from 'src/const/Statuses' +import { PostMessageContext } from 'src/ConnectWidget' +import { POST_MESSAGES } from 'src/const/postMessages' +import { STEPS } from 'src/const/Connect' + +const institutionMock = { + name: 'Institution', + guid: 'INS-123', +} +const memberMock = { + guid: 'MEM-123', + error: {}, + name: 'Member', + connection_status: ReadableStatuses.EXPIRED, +} + +describe('LoginError', () => { + const initialState = { + ...defaultState, + connect: { + ...defaultState.connect, + selectedInstitution: institutionMock, + currentMemberGuid: memberMock.guid, + members: [memberMock], + location: [{ step: 'LOGIN_ERROR' }], + }, + } + const defaultProps = { + institution: institutionMock, + isDeleteInstitutionOptionEnabled: true, + member: memberMock, + onDeleteConnectionClick: vitest.fn(), + onRefreshClick: vitest.fn(), + onUpdateCredentialsClick: vitest.fn(), + showExternalLinkPopup: false, + showSupport: true, + size: 'medium', + } + let onPostMessage + + const renderLoginError = (props = {}, preloadedState = initialState) => + render( + + + , + { preloadedState }, + ) + + const renderStepProps = { + availableAccountTypes: [], + handleConsentGoBack: vitest.fn(), + handleCredentialsGoBack: vitest.fn(), + navigationRef: React.createRef(), + onManualAccountAdded: vitest.fn(), + onUpsertMember: vitest.fn(), + setConnectLocalState: vitest.fn(), + } + + const renderErrorStep = (connection_status, preloadedState = initialState) => { + const member = { ...memberMock, connection_status } + + return render(, { + preloadedState: { + ...preloadedState, + connect: { + ...preloadedState.connect, + location: [{ step: STEPS.ACTIONABLE_ERROR }], + selectedInstitution: institutionMock, + currentMemberGuid: member.guid, + members: [member], + }, + }, + }) + } + + beforeEach(() => { + vitest.clearAllMocks() + onPostMessage = vitest.fn() + }) + + it('renders the institution logo without an error badge', () => { + renderLoginError() + + const institutionLogo = screen.getByRole('img') + expect(institutionLogo).toBeInTheDocument() + expect(institutionLogo).toHaveAttribute('alt', `${institutionMock.name} logo`) + expect(screen.queryByText('!')).not.toBeInTheDocument() + }) + + it('posts a member error message on mount', () => { + renderLoginError() + + expect(onPostMessage).toHaveBeenCalledWith('connect/memberError', { + member: { + guid: memberMock.guid, + connection_status: memberMock.connection_status, + }, + }) + }) + + describe('Connection Status Variants', () => { + it.each([ + { status: ReadableStatuses.PREVENTED, title: 'New credentials needed', button: 'Connect' }, + { + status: ReadableStatuses.DENIED, + title: 'Please re-enter your credentials', + button: 'Connect', + }, + { status: ReadableStatuses.REJECTED, title: 'Incorrect information', button: 'Try again' }, + { status: ReadableStatuses.LOCKED, title: 'Account is locked' }, + { status: ReadableStatuses.DEGRADED, title: 'Connection maintenance', button: 'OK' }, + { status: ReadableStatuses.DISCONNECTED, title: 'Connection maintenance' }, + { status: ReadableStatuses.DISCONTINUED, title: 'Connection discontinued' }, + { status: ReadableStatuses.CLOSED, title: 'Closed account' }, + { status: ReadableStatuses.FAILED, title: 'Connection failed' }, + { status: ReadableStatuses.DISABLED, title: 'Connection disabled' }, + { status: ReadableStatuses.IMPORTED, title: 'New credentials needed', button: 'Connect' }, + { status: ReadableStatuses.CHALLENGED, title: 'Something went wrong', button: 'Try again' }, + { status: ReadableStatuses.IMPAIRED, title: 'New credentials needed', button: 'Connect' }, + ])( + 'renders the "$title" view for the $status connection status', + ({ status, title, button }) => { + renderLoginError({ member: { ...memberMock, connection_status: status } }) + + expect(screen.getByText(title)).toBeInTheDocument() + if (button) { + expect(screen.getByRole('button', { name: button })).toBeInTheDocument() + } + }, + ) + + it('renders a generic error for an unknown connection status', () => { + renderLoginError({ member: { ...memberMock, connection_status: 'UNKNOWN_STATUS' } }) + + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect( + screen.getByText( + "We've notified support and are looking into the issue. Please try again later.", + ), + ).toBeInTheDocument() + }) + }) + + describe('Primary Actions', () => { + it('returns to connecting when the Try again button is clicked', async () => { + const { user } = renderErrorStep(ReadableStatuses.REJECTED) + + expect(await screen.findByText('Incorrect information')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Try again' })) + + expect(screen.queryByText('Incorrect information')).not.toBeInTheDocument() + }) + + it('opens the update credentials form when the Connect button is clicked', async () => { + const { user } = renderErrorStep(ReadableStatuses.PREVENTED) + + await user.click(await screen.findByRole('button', { name: 'Connect' })) + + expect(await screen.findByLabelText('Username *')).toBeInTheDocument() + }) + + it('posts a primary action message when the OK button is clicked', async () => { + const member = { ...memberMock, connection_status: ReadableStatuses.DEGRADED } + const { user } = renderLoginError({ member }) + + await user.click(screen.getByRole('button', { name: 'OK' })) + + expect(onPostMessage).toHaveBeenCalledWith('connect/memberError/primaryAction', { + member: { + guid: member.guid, + connection_status: member.connection_status, + }, + }) + }) + + it('opens the update credentials form when OK is clicked and institution search is disabled', async () => { + const stateWithDisabledSearch = { + ...initialState, + config: { + ...initialState.config, + disable_institution_search: true, + }, + } + const { user } = renderErrorStep(ReadableStatuses.DEGRADED, stateWithDisabledSearch) + + await user.click(await screen.findByRole('button', { name: 'OK' })) + + expect(await screen.findByLabelText('Username *')).toBeInTheDocument() + }) + }) + + describe('Secondary Actions', () => { + it('opens the support view when the Get help button is clicked', async () => { + const { user } = renderLoginError({ + member: { ...memberMock, connection_status: ReadableStatuses.PREVENTED }, + }) + + expect(screen.getByText('Get help')).toBeInTheDocument() + + await user.click(screen.getByText('Get help')) + + expect(await screen.findByText('Request support')).toBeInTheDocument() + expect(screen.queryByText('New credentials needed')).not.toBeInTheDocument() + }) + + it('posts back to search when the Try another institution action is clicked', async () => { + const { user } = renderLoginError({ + member: { ...memberMock, connection_status: ReadableStatuses.REJECTED }, + }) + + await user.click(screen.getByText('Try another institution')) + + expect(onPostMessage).toHaveBeenCalledWith(POST_MESSAGES.BACK_TO_SEARCH) + }) + }) + + describe('Navigation back button', () => { + it('exposes a back button in support and returns to the error when invoked', async () => { + const ref = React.createRef() + const { user } = renderLoginError({ + member: { ...memberMock, connection_status: ReadableStatuses.PREVENTED }, + ref, + }) + + expect(ref.current.showBackButton()).toBe(false) + + await user.click(screen.getByText('Get help')) + expect(await screen.findByText('Request support')).toBeInTheDocument() + expect(ref.current.showBackButton()).toBe(true) + + act(() => { + ref.current.handleBackButton() + }) + + expect(await screen.findByText('New credentials needed')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/loginError/NoEligibleAccountsError.js b/src/views/loginError/NoEligibleAccountsError.js deleted file mode 100644 index 01306e10f7..0000000000 --- a/src/views/loginError/NoEligibleAccountsError.js +++ /dev/null @@ -1,154 +0,0 @@ -import React, { useContext } from 'react' -import { useDispatch, useSelector } from 'react-redux' - -import { useTokens } from '@kyper/tokenprovider' -import { Text } from '@mxenabled/mxui' -import { AttentionFilled } from '@kyper/icon/AttentionFilled' -import { Button } from '@mui/material' - -import { __ } from 'src/utilities/Intl' -import { ActionTypes } from 'src/redux/actions/Connect' - -import { getCurrentMember, getSelectedInstitution } from 'src/redux/selectors/Connect' - -import { AriaLive } from 'src/components/AriaLive' -import { SlideDown } from 'src/components/SlideDown' -import { getDelay } from 'src/utilities/getDelay' -import useAnalyticsEvent from 'src/hooks/useAnalyticsEvent' -import { AnalyticEvents, AuthenticationMethods } from 'src/const/Analytics' -import { POST_MESSAGES } from 'src/const/postMessages' -import { PostMessageContext } from 'src/ConnectWidget' - -export const NoEligibleAccounts = () => { - const sendAnalyticsEvent = useAnalyticsEvent() - const tokens = useTokens() - const styles = getStyles(tokens) - const postMessageFunctions = useContext(PostMessageContext) - const dispatch = useDispatch() - - const currentMember = useSelector(getCurrentMember) - const selectedInstitution = useSelector(getSelectedInstitution) - - const postHogEventMetadata = { - authentication_method: currentMember.is_oauth - ? AuthenticationMethods.OAUTH - : AuthenticationMethods.NON_OAUTH, - institution_guid: selectedInstitution.guid, - institution_name: selectedInstitution.name, - } - - const getNextDelay = getDelay() - - return ( - - -
- - {__('Accounts not eligible for transfers')} - - -
-
- - {__( - "We've connected to your financial institution, but couldn't find eligible checking or savings accounts for money movement; however, other account information may still have been shared.", - )} - - - {__('Please try linking a checking or savings account.')} - - -
- -
- - -
- ) -} - -const getStyles = (tokens) => { - return { - headerText: { - fontWeight: tokens.FontWeight.Bold, - }, - headerContianer: { - display: 'flex', - alignItems: 'center', - }, - icon: { - marginLeft: tokens.Spacing.Small, - }, - paragraphOne: { - fontWeight: tokens.FontWeight.Regular, - fontSize: tokens.FontSize.Small, - marginTop: tokens.Spacing.XSmall, - }, - paragraphTwo: { - fontWeight: tokens.FontWeight.Regular, - fontSize: tokens.FontSize.Small, - marginTop: tokens.Spacing.Medium, - }, - tryAgainButton: { - background: tokens.BackgroundColor.ButtonPrimary, - color: tokens.Color.NeutralWhite, - marginTop: tokens.Spacing.XLarge, - borderRadius: tokens.BorderRadius.Medium, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - padding: '12px 16px', - gap: '10px', - height: '44px', - width: '100%', - }, - } -} - -NoEligibleAccounts.propTypes = {} - -export default NoEligibleAccounts diff --git a/src/views/loginError/__tests__/LoginError-test.jsx b/src/views/loginError/__tests__/LoginError-test.jsx deleted file mode 100644 index b79b4e461f..0000000000 --- a/src/views/loginError/__tests__/LoginError-test.jsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react' -import { render, screen } from 'src/utilities/testingLibrary' -import { LoginError } from 'src/views/loginError/LoginError' -import { PageviewInfo } from 'src/const/Analytics' -import { useAnalyticsPath } from 'src/hooks/useAnalyticsPath' -import { initialState as defaultState } from 'src/services/mockedData' -import { ConnectionStatusMap, ReadableStatuses } from 'src/const/Statuses' - -const institutionMock = { - name: 'Institution', - guid: 'INS-123', -} -const memberMock = { - guid: 'MEM-123', - error: {}, - name: 'Member', - connection_status: ReadableStatuses.EXPIRED, -} - -vitest.mock('src/hooks/useAnalyticsPath', { spy: true }) - -describe('LoginError', () => { - const initialState = { - ...defaultState, - connect: { - ...defaultState.connect, - selectedInstitution: institutionMock, - currentMemberGuid: memberMock.guid, - members: [memberMock], - location: [{ step: 'LOGIN_ERROR' }], - }, - } - const defaultProps = { - institution: institutionMock, - isDeleteInstitutionOptionEnabled: true, - member: memberMock, - onDeleteConnectionClick: vitest.fn(), - onRefreshClick: vitest.fn(), - onUpdateCredentialsClick: vitest.fn(), - showExternalLinkPopup: false, - showSupport: true, - size: 'medium', - } - - beforeEach(() => { - vitest.clearAllMocks() - }) - - it('should fire a pageview event with correct parameters', () => { - render(, { - preloadedState: initialState, - }) - expect(useAnalyticsPath).toHaveBeenCalledWith(...PageviewInfo.CONNECT_LOGIN_ERROR, { - connection_status: memberMock.connection_status, - readable_status: ConnectionStatusMap[memberMock.connection_status], - }) - }) - - it('should render an institution logo without a badge', () => { - render(, { - preloadedState: initialState, - }) - const institutionLogo = screen.getByRole('img') - expect(institutionLogo).toBeInTheDocument() - expect(institutionLogo).toHaveAttribute('alt', `${institutionMock.name} logo`) - - const badge = screen.queryByText('!') - expect(badge).not.toBeInTheDocument() - }) -}) diff --git a/src/views/manualAccount/ManualAccountConnect-test.tsx b/src/views/manualAccount/ManualAccountConnect-test.tsx new file mode 100644 index 0000000000..9d43d25dc4 --- /dev/null +++ b/src/views/manualAccount/ManualAccountConnect-test.tsx @@ -0,0 +1,242 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { type UserEvent } from '@testing-library/user-event' +import { render, screen } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { ConnectNavigationHeader } from 'src/components/ConnectNavigationHeader' +import { initialState, institutionData, member } from 'src/services/mockedData' +import { apiValue as baseApiValue } from 'src/const/apiProviderMock' +import { PostMessageContext } from 'src/ConnectWidget' +import { AccountTypes } from 'src/views/manualAccount/constants' +import { STEPS } from 'src/const/Connect' + +type NavigationHandle = { + handleBackButton: () => void + showBackButton: () => HTMLElement | false | null +} + +type RenderStepOptions = { + apiOverrides?: Partial + availableAccountTypes?: number[] + onManualAccountAdded?: ReturnType +} + +const manualAccountState = { + ...initialState, + connect: { + ...initialState.connect, + location: [{ step: STEPS.ADD_MANUAL_ACCOUNT }], + }, +} + +const buildMockApi = (apiOverrides: Partial = {}) => ({ + ...baseApiValue, + createAccount: vi.fn( + (): Promise => + Promise.resolve({ + member_guid: member.member.guid, + institution_guid: institutionData.institution.guid, + } as unknown as AccountResponseType), + ), + ...apiOverrides, +}) + +const renderManualAccountStep = ({ + apiOverrides = {}, + availableAccountTypes = [AccountTypes.CHECKING, AccountTypes.SAVINGS], + onManualAccountAdded = vi.fn(), +}: RenderStepOptions = {}) => { + const onPostMessage = vi.fn() + const mockApi = buildMockApi(apiOverrides) + + return { + ...render( + + {}} + handleCredentialsGoBack={() => {}} + navigationRef={React.createRef()} + onManualAccountAdded={onManualAccountAdded} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + /> + , + { apiValue: mockApi, preloadedState: manualAccountState }, + ), + mockApi, + onManualAccountAdded, + onPostMessage, + } +} + +const ManualAccountWithNavigationHeader = ({ + availableAccountTypes, + onManualAccountAdded, +}: { + availableAccountTypes: number[] + onManualAccountAdded: () => void +}) => { + const [stepComponentRef, setStepComponentRef] = React.useState(null) + + return ( + <> + {}} stepComponentRef={stepComponentRef} /> + {}} + handleCredentialsGoBack={() => {}} + navigationRef={setStepComponentRef} + onManualAccountAdded={onManualAccountAdded} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + /> + + ) +} + +const renderWithNavigationHeader = ({ + apiOverrides = {}, + availableAccountTypes = [AccountTypes.CHECKING, AccountTypes.SAVINGS], + onManualAccountAdded = vi.fn(), +}: RenderStepOptions = {}) => { + const onPostMessage = vi.fn() + const mockApi = buildMockApi(apiOverrides) + + return { + ...render( + + + , + { apiValue: mockApi, preloadedState: manualAccountState }, + ), + mockApi, + onManualAccountAdded, + onPostMessage, + } +} + +const addManualCheckingAccount = async (user: UserEvent) => { + await user.click(await screen.findByRole('button', { name: 'Checking' })) + await user.type(await screen.findByTestId('text-input-user_name'), 'My Checking') + await user.click(screen.getByTestId('save-manual-account-button')) +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Content Display', () => { + it('renders the manual account menu with the available account types', async () => { + renderManualAccountStep() + + expect(await screen.findByTestId('manual-account-menu-container')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Checking' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Savings' })).toBeInTheDocument() + }) + + it('shows only the provided account type when a single type is available', async () => { + renderManualAccountStep({ availableAccountTypes: [AccountTypes.CHECKING] }) + + expect(await screen.findByRole('button', { name: 'Checking' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Savings' })).not.toBeInTheDocument() + }) + }) + + describe('Account Type Selection', () => { + it('opens the manual account form for the selected account type', async () => { + const { user } = renderManualAccountStep() + + await user.click(await screen.findByRole('button', { name: 'Checking' })) + + expect(await screen.findByTestId('manual-account-form-header')).toHaveTextContent('Checking') + expect(screen.queryByTestId('manual-account-menu-container')).not.toBeInTheDocument() + }) + + it('opens the form for a different account type', async () => { + const { user } = renderManualAccountStep() + + await user.click(await screen.findByRole('button', { name: 'Savings' })) + + expect(await screen.findByTestId('manual-account-form-header')).toHaveTextContent('Savings') + }) + }) + + describe('Success Flow', () => { + it('creates the account and shows the success view after saving', async () => { + const { user, mockApi } = renderManualAccountStep() + + await addManualCheckingAccount(user) + + expect(await screen.findByTestId('manual-account-success-header')).toHaveTextContent( + 'Checking added', + ) + expect(mockApi.createAccount).toHaveBeenCalledWith( + expect.objectContaining({ + account_type: AccountTypes.CHECKING, + user_name: 'My Checking', + }), + ) + expect(await screen.findByTestId('manual-account-success-header')).toBeInTheDocument() + }) + + it('shows an error when account creation fails', async () => { + const { user } = renderManualAccountStep({ + apiOverrides: { + createAccount: vi.fn().mockRejectedValue(new Error('Failed to create account')), + }, + }) + + await addManualCheckingAccount(user) + + expect(await screen.findByTestId('something-went-wrong-text')).toBeInTheDocument() + expect(screen.queryByTestId('manual-account-success-header')).not.toBeInTheDocument() + }) + + it('posts back to search when Done is clicked on the success view', async () => { + const { user } = renderManualAccountStep() + + await addManualCheckingAccount(user) + + await user.click(await screen.findByTestId('manual-success-done-button')) + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + }) + }) + + describe('Navigation back button', () => { + it('returns from the form to the menu when the back button is clicked', async () => { + const { user } = renderWithNavigationHeader() + + await user.click(await screen.findByRole('button', { name: 'Checking' })) + expect(await screen.findByTestId('manual-account-form-header')).toBeInTheDocument() + + await user.click(await screen.findByTestId('back-button')) + + expect(await screen.findByTestId('manual-account-menu-container')).toBeInTheDocument() + expect(screen.queryByTestId('manual-account-form-header')).not.toBeInTheDocument() + }) + + it('returns to search when the back button is clicked from the menu', async () => { + const { user } = renderWithNavigationHeader() + + await user.click(await screen.findByTestId('back-button')) + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + }) + + it('hides the back button on the success view', async () => { + const { user } = renderWithNavigationHeader() + + expect(await screen.findByTestId('back-button')).toBeInTheDocument() + + await addManualCheckingAccount(user) + await screen.findByTestId('manual-account-success-header') + + expect(screen.queryByTestId('back-button')).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/views/manualAccount/ManualAccountForm-test.tsx b/src/views/manualAccount/ManualAccountForm-test.tsx new file mode 100644 index 0000000000..302401df62 --- /dev/null +++ b/src/views/manualAccount/ManualAccountForm-test.tsx @@ -0,0 +1,274 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import RenderConnectStep from 'src/components/RenderConnectStep' +import { initialState, institutionData, member } from 'src/services/mockedData' +import { apiValue as baseApiValue } from 'src/const/apiProviderMock' +import { PostMessageContext } from 'src/ConnectWidget' +import { AccountTypes } from 'src/views/manualAccount/constants' +import { STEPS } from 'src/const/Connect' + +type RenderFormOptions = { + accountType?: number + apiOverrides?: Partial + preloadedMembers?: Array<{ institution_guid: string }> +} + +const accountTypeButtonName: Record = { + [AccountTypes.CHECKING]: 'Checking', + [AccountTypes.SAVINGS]: 'Savings', + [AccountTypes.LOAN]: 'Loan', + [AccountTypes.CREDIT_CARD]: 'Credit Card', +} + +const buildMockApi = (apiOverrides: Partial = {}) => ({ + ...baseApiValue, + createAccount: vi.fn( + (): Promise => + Promise.resolve({ + member_guid: member.member.guid, + institution_guid: institutionData.institution.guid, + } as unknown as AccountResponseType), + ), + ...apiOverrides, +}) + +const renderManualAccountForm = async ({ + accountType = AccountTypes.CHECKING, + apiOverrides = {}, + preloadedMembers = [], +}: RenderFormOptions = {}) => { + const mockApi = buildMockApi(apiOverrides) + + const utils = render( + + {}} + handleCredentialsGoBack={() => {}} + navigationRef={React.createRef()} + onManualAccountAdded={() => {}} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + /> + , + { + apiValue: mockApi, + preloadedState: { + ...initialState, + connect: { + ...initialState.connect, + location: [{ step: STEPS.ADD_MANUAL_ACCOUNT }], + members: preloadedMembers, + }, + }, + }, + ) + + await utils.user.click( + await screen.findByRole('button', { name: accountTypeButtonName[accountType] }), + ) + await screen.findByTestId('manual-account-form-header') + + return { ...utils, mockApi } +} + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Content Display', () => { + it('renders the checking account form fields', async () => { + await renderManualAccountForm() + + expect(screen.getByTestId('manual-account-form-header')).toHaveTextContent('Checking') + expect(screen.getByLabelText(/account name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/account balance/i)).toBeInTheDocument() + expect(screen.getByText(/required/i)).toBeInTheDocument() + expect(screen.getByTestId('save-manual-account-button')).toHaveTextContent('Save') + expect(screen.queryByLabelText(/credit limit/i)).not.toBeInTheDocument() + }) + + it('renders the header for the selected account type', async () => { + await renderManualAccountForm({ accountType: AccountTypes.SAVINGS }) + + expect(screen.getByTestId('manual-account-form-header')).toHaveTextContent('Savings') + }) + }) + + describe('Account Type Specific Fields', () => { + it('renders the credit card specific fields', async () => { + await renderManualAccountForm({ accountType: AccountTypes.CREDIT_CARD }) + + expect(screen.getByLabelText(/credit limit/i)).toBeInTheDocument() + expect(screen.getByLabelText(/minimum payment/i)).toBeInTheDocument() + expect(screen.getByLabelText(/day of the month payment is due/i)).toBeInTheDocument() + }) + + it('renders the interest rate field for loan accounts', async () => { + await renderManualAccountForm({ accountType: AccountTypes.LOAN }) + + expect(screen.getByLabelText(/interest rate/i)).toBeInTheDocument() + }) + }) + + describe('Personal and Business Selection', () => { + it('selects personal by default', async () => { + await renderManualAccountForm() + + expect(screen.getByLabelText('Personal')).toBeChecked() + expect(screen.getByLabelText('Business')).not.toBeChecked() + }) + + it('toggles between personal and business', async () => { + const { user } = await renderManualAccountForm() + + await user.click(screen.getByLabelText('Business')) + + expect(screen.getByLabelText('Business')).toBeChecked() + expect(screen.getByLabelText('Personal')).not.toBeChecked() + + await user.click(screen.getByLabelText('Personal')) + + expect(screen.getByLabelText('Personal')).toBeChecked() + expect(screen.getByLabelText('Business')).not.toBeChecked() + }) + }) + + describe('Day Picker', () => { + it('opens the day picker and returns to the form when the back button is clicked', async () => { + const { user } = await renderManualAccountForm({ accountType: AccountTypes.CREDIT_CARD }) + + await user.click(screen.getByLabelText(/day of the month payment is due/i)) + + expect(await screen.findByText('Payment due day')).toBeInTheDocument() + expect(screen.queryByTestId('manual-account-form-header')).not.toBeInTheDocument() + + await user.click(screen.getByTestId('back-button')) + + expect(await screen.findByTestId('manual-account-form-header')).toBeInTheDocument() + }) + + it('selects a day and returns to the form', async () => { + const { user } = await renderManualAccountForm({ accountType: AccountTypes.CREDIT_CARD }) + + await user.click(screen.getByLabelText(/day of the month payment is due/i)) + await user.click(await screen.findByTestId('date-picker-button-15')) + + expect(await screen.findByTestId('manual-account-form-header')).toBeInTheDocument() + expect(screen.getByLabelText(/day of the month payment is due/i)).toHaveValue('15') + }) + }) + + describe('Form Validation', () => { + it('shows a required field error only after attempting to save without an account name', async () => { + const { user, mockApi } = await renderManualAccountForm() + + expect(screen.queryByText(/is required/i)).not.toBeInTheDocument() + + await user.click(screen.getByTestId('save-manual-account-button')) + + expect((await screen.findAllByText(/is required/i)).length).toBeGreaterThan(0) + expect(mockApi.createAccount).not.toHaveBeenCalled() + }) + }) + + describe('Form Submission', () => { + it('creates the account with the entered values and shows the success view', async () => { + const { user, mockApi } = await renderManualAccountForm() + + await user.type(screen.getByLabelText(/account name/i), 'Test Account') + await user.type(screen.getByLabelText(/account balance/i), '1000') + await user.click(screen.getByTestId('save-manual-account-button')) + + expect(await screen.findByTestId('manual-account-success-header')).toBeInTheDocument() + expect(mockApi.createAccount).toHaveBeenCalledWith( + expect.objectContaining({ + user_name: 'Test Account', + balance: '1000', + account_type: AccountTypes.CHECKING, + is_personal: true, + }), + ) + }) + + it('creates a business account when business is selected', async () => { + const { user, mockApi } = await renderManualAccountForm() + + await user.click(screen.getByLabelText('Business')) + await user.type(screen.getByLabelText(/account name/i), 'Business Account') + await user.click(screen.getByTestId('save-manual-account-button')) + + expect(await screen.findByTestId('manual-account-success-header')).toBeInTheDocument() + expect(mockApi.createAccount).toHaveBeenCalledWith( + expect.objectContaining({ is_personal: false }), + ) + }) + + it('creates a savings account for the savings account type', async () => { + const { user, mockApi } = await renderManualAccountForm({ accountType: AccountTypes.SAVINGS }) + + await user.type(screen.getByLabelText(/account name/i), 'Savings Account') + await user.click(screen.getByTestId('save-manual-account-button')) + + expect(await screen.findByTestId('manual-account-success-header')).toBeInTheDocument() + expect(mockApi.createAccount).toHaveBeenCalledWith( + expect.objectContaining({ account_type: AccountTypes.SAVINGS }), + ) + }) + + it('creates the account when a manual member already exists', async () => { + const { user } = await renderManualAccountForm({ + preloadedMembers: [{ institution_guid: 'INS-MANUAL-456' }], + }) + + await user.type(screen.getByLabelText(/account name/i), 'My Account') + await user.click(screen.getByTestId('save-manual-account-button')) + + expect(await screen.findByTestId('manual-account-success-header')).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('shows an error and re-enables the save button when account creation fails', async () => { + const { user } = await renderManualAccountForm({ + apiOverrides: { + createAccount: vi.fn().mockRejectedValue(new Error('Network error')), + }, + }) + + await user.type(screen.getByLabelText(/account name/i), 'My Account') + await user.click(screen.getByTestId('save-manual-account-button')) + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByTestId('something-went-wrong-text')).toHaveTextContent( + 'Please try saving your account again.', + ) + expect(screen.getByTestId('save-manual-account-button')).not.toBeDisabled() + }) + + it('allows retrying after an error and reaches the success view', async () => { + const { user } = await renderManualAccountForm({ + apiOverrides: { + createAccount: vi + .fn() + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + member_guid: member.member.guid, + institution_guid: institutionData.institution.guid, + }), + }, + }) + + await user.type(screen.getByLabelText(/account name/i), 'My Account') + await user.click(screen.getByTestId('save-manual-account-button')) + + await screen.findByText('Something went wrong') + + await user.click(screen.getByTestId('save-manual-account-button')) + + expect(await screen.findByTestId('manual-account-success-header')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/mfa/DefaultMFA-test.tsx b/src/views/mfa/DefaultMFA-test.tsx new file mode 100644 index 0000000000..91041dbf55 --- /dev/null +++ b/src/views/mfa/DefaultMFA-test.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { DefaultMFA } from 'src/views/mfa/DefaultMFA' +import { AnalyticEvents, defaultEventMetadata } from 'src/const/Analytics' +import { sha256 } from 'js-sha256' +import { institutionData, member, MFA_CREDENTIALS } from 'src/services/mockedData' + +type MfaCredential = { + guid: string + label: string + [key: string]: unknown +} + +describe('', () => { + const currentMember = member.member + const institution = institutionData.institution + const [securityQuestion] = MFA_CREDENTIALS + const pinCredential: MfaCredential = { + guid: 'CRD-002', + label: 'PIN', + field_name: 'PIN', + field_type: 0, + } + + let onAnalyticsEvent: ReturnType + let onSubmit: ReturnType + + beforeEach(() => { + onAnalyticsEvent = vi.fn() + onSubmit = vi.fn() + }) + + const renderDefaultMFA = ( + mfaCredentials: MfaCredential[] = MFA_CREDENTIALS, + isSubmitting = false, + ) => + render( + , + { onAnalyticsEvent }, + ) + + describe('Content Display', () => { + it('renders the credential prompt, required note, and continue button', () => { + renderDefaultMFA() + + expect(screen.getByText(securityQuestion.label, { exact: false })).toBeInTheDocument() + expect(screen.getByText(/required/i)).toBeInTheDocument() + expect(screen.getByTestId('continue-button')).toHaveTextContent('Continue') + }) + + it('renders an input for each MFA credential', () => { + renderDefaultMFA([securityQuestion, pinCredential]) + + expect(screen.getByText(securityQuestion.label, { exact: false })).toBeInTheDocument() + expect(screen.getByText('PIN', { exact: false })).toBeInTheDocument() + expect(screen.getAllByRole('textbox')).toHaveLength(2) + }) + + it.each(['meta_data', 'image_data'])('renders the challenge image provided via %s', (field) => { + renderDefaultMFA([{ ...securityQuestion, [field]: 'data:image/png;base64,abc123' }]) + + expect(screen.getByAltText('Challenge Image')).toHaveAttribute( + 'src', + 'data:image/png;base64,abc123', + ) + }) + + it('does not render a challenge image when none is provided', () => { + renderDefaultMFA() + + expect(screen.queryByAltText('Challenge Image')).not.toBeInTheDocument() + }) + }) + + describe('Submitting State', () => { + it('shows a checking label and disables the input while submitting', () => { + renderDefaultMFA(MFA_CREDENTIALS, true) + + expect(screen.getByTestId('continue-button')).toHaveTextContent(/Checking/i) + expect(screen.getByRole('textbox')).toBeDisabled() + }) + }) + + describe('Form Submission', () => { + it('submits the entered credential value', async () => { + const { user } = renderDefaultMFA() + + await user.type(screen.getByRole('textbox'), '123456') + await user.click(screen.getByTestId('continue-button')) + + expect(onSubmit).toHaveBeenCalledWith([{ guid: securityQuestion.guid, value: '123456' }]) + }) + + it('submits a value for every credential', async () => { + const { user } = renderDefaultMFA([securityQuestion, pinCredential]) + + const inputs = screen.getAllByRole('textbox') + await user.type(inputs[0], '123456') + await user.type(inputs[1], '9876') + await user.click(screen.getByTestId('continue-button')) + + expect(onSubmit).toHaveBeenCalledWith([ + { guid: securityQuestion.guid, value: '123456' }, + { guid: pinCredential.guid, value: '9876' }, + ]) + }) + }) + + describe('Form Validation', () => { + it('does not submit and shows a required error when the field is empty', async () => { + const { user } = renderDefaultMFA() + + await user.click(screen.getByTestId('continue-button')) + + const errors = await screen.findAllByText((content) => + content.includes(`${securityQuestion.label} is required`), + ) + expect(errors.length).toBeGreaterThan(0) + expect(onSubmit).not.toHaveBeenCalled() + }) + }) + + describe('Analytics', () => { + it('sends the MFA entered-input event only on the first keystroke', async () => { + const { user } = renderDefaultMFA() + const input = screen.getByRole('textbox') + + await user.type(input, '1') + + expect(onAnalyticsEvent).toHaveBeenCalledWith( + `connect_${AnalyticEvents.MFA_ENTERED_INPUT}`, + expect.objectContaining({ + institution_guid: institution.guid, + institution_name: institution.name, + member_guid: sha256(currentMember.guid), + widgetType: defaultEventMetadata.widgetType, + }), + ) + expect(onAnalyticsEvent).toHaveBeenCalledTimes(1) + + await user.type(input, '23456') + + expect(onAnalyticsEvent).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/views/mfa/MFAForm-test.tsx b/src/views/mfa/MFAForm-test.tsx new file mode 100644 index 0000000000..bf75868028 --- /dev/null +++ b/src/views/mfa/MFAForm-test.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from 'src/utilities/testingLibrary' +import { MFAForm } from 'src/views/mfa/MFAForm' +import { AnalyticEvents, defaultEventMetadata } from 'src/const/Analytics' +import { sha256 } from 'js-sha256' +import { member, institutionData, MFA_CREDENTIALS } from 'src/services/mockedData' +import { CredentialTypes } from 'src/const/Credential' + +type MFAOption = { + guid: string + credential_guid: string + label: string + value: string + data_uri?: string +} + +type MFACredential = Omit<(typeof MFA_CREDENTIALS)[0], 'options'> & { + options?: MFAOption[] +} + +describe('', () => { + const institution = institutionData.institution + const memberGuidHash = sha256(member.member.guid) + + const createMember = (credentials: MFACredential[] = MFA_CREDENTIALS) => ({ + ...member.member, + mfa: { + credentials, + }, + }) + + const optionsCredentials: MFACredential[] = [ + { + ...MFA_CREDENTIALS[0], + field_type: CredentialTypes.OPTIONS, + options: [ + { + guid: 'OPT-001', + credential_guid: MFA_CREDENTIALS[0].guid, + label: 'Option 1', + value: 'option1', + }, + { + guid: 'OPT-002', + credential_guid: MFA_CREDENTIALS[0].guid, + label: 'Option 2', + value: 'option2', + }, + ], + }, + ] + + const imageCredentials: MFACredential[] = [ + { + ...MFA_CREDENTIALS[0], + field_type: CredentialTypes.IMAGE_OPTIONS, + options: [ + { + guid: 'IMG-001', + credential_guid: MFA_CREDENTIALS[0].guid, + label: 'Image 1', + value: 'image1', + data_uri: 'data:image/png;base64,abc', + }, + { + guid: 'IMG-002', + credential_guid: MFA_CREDENTIALS[0].guid, + label: 'Image 2', + value: 'image2', + data_uri: 'data:image/png;base64,def', + }, + ], + }, + ] + + let onAnalyticsEvent: ReturnType + let onSubmit: ReturnType + + beforeEach(() => { + onAnalyticsEvent = vi.fn() + onSubmit = vi.fn() + }) + + const renderMFAForm = (currentMember = createMember()) => + render( + , + { onAnalyticsEvent }, + ) + + describe('Title', () => { + it('renders the verify identity title for a standard MFA challenge', () => { + renderMFAForm() + + expect(screen.getByText('Verify identity')).toBeInTheDocument() + }) + + it('renders the account selection title for a single account select challenge', () => { + const sasCredentials: MFACredential[] = [ + { ...MFA_CREDENTIALS[0], external_id: 'single_account_select' }, + ] + + renderMFAForm(createMember(sasCredentials)) + + expect(screen.getByText('Account selection')).toBeInTheDocument() + }) + }) + + describe('Submits the selected credentials', () => { + it('submits the typed value and reports the input event', async () => { + const { user } = renderMFAForm() + + await user.type(screen.getByRole('textbox'), 'test123') + await user.click(screen.getByRole('button', { name: /continue/i })) + + expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'test123' }]) + expect(onAnalyticsEvent).toHaveBeenCalledWith( + `connect_${AnalyticEvents.MFA_SUBMITTED_INPUT}`, + expect.objectContaining({ + institution_guid: institution.guid, + institution_name: institution.name, + member_guid: memberGuidHash, + widgetType: defaultEventMetadata.widgetType, + }), + ) + }) + + it('submits the typed value when the user presses Enter in the field', async () => { + const { user } = renderMFAForm() + + await user.type(screen.getByRole('textbox'), 'test123{Enter}') + + expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'test123' }]) + }) + + it('submits the chosen option and reports the option event', async () => { + const { user } = renderMFAForm(createMember(optionsCredentials)) + + await user.click(screen.getByText('Option 1')) + await user.click(screen.getByRole('button', { name: /continue/i })) + + expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'OPT-001' }]) + expect(onAnalyticsEvent).toHaveBeenCalledWith( + `connect_${AnalyticEvents.MFA_SUBMITTED_OPTION}`, + expect.objectContaining({ + institution_guid: institution.guid, + institution_name: institution.name, + member_guid: memberGuidHash, + widgetType: defaultEventMetadata.widgetType, + }), + ) + }) + + it('submits the chosen image and reports the image event', async () => { + const { user } = renderMFAForm(createMember(imageCredentials)) + + await user.click(screen.getAllByRole('img')[0]) + await user.click(screen.getByRole('button', { name: /continue/i })) + + expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'IMG-001' }]) + expect(onAnalyticsEvent).toHaveBeenCalledWith( + `connect_${AnalyticEvents.MFA_SUBMITTED_IMAGE}`, + expect.objectContaining({ + institution_guid: institution.guid, + institution_name: institution.name, + member_guid: memberGuidHash, + widgetType: defaultEventMetadata.widgetType, + }), + ) + }) + }) + + describe('Validation', () => { + it('blocks submission with an error until an option is selected', async () => { + const { user } = renderMFAForm(createMember(optionsCredentials)) + + await user.click(screen.getByRole('button', { name: /continue/i })) + expect(screen.getByText('Choose an option')).toBeInTheDocument() + + await user.click(screen.getByText('Option 1')) + expect(screen.queryByText('Choose an option')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /continue/i })) + expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'OPT-001' }]) + }) + + it('blocks submission with an error until an image is selected', async () => { + const { user } = renderMFAForm(createMember(imageCredentials)) + + await user.click(screen.getByRole('button', { name: /continue/i })) + expect(screen.getByText('Choose an image')).toBeInTheDocument() + + await user.click(screen.getAllByRole('img')[0]) + expect(screen.queryByText('Choose an image')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /continue/i })) + expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'IMG-001' }]) + }) + + it('blocks submission with an error until an account is selected', async () => { + const sasCredentials: MFACredential[] = [ + { ...optionsCredentials[0], external_id: 'single_account_select' }, + ] + const { user } = renderMFAForm(createMember(sasCredentials)) + + expect(screen.getByText('Select an account to connect')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /continue/i })) + expect(screen.getByText('Account selection is required.')).toBeInTheDocument() + + await user.click(screen.getByText('Option 1')) + expect(screen.queryByText('Account selection is required.')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /continue/i })) + expect(onSubmit).toHaveBeenCalledWith([{ guid: MFA_CREDENTIALS[0].guid, value: 'OPT-001' }]) + }) + }) +}) diff --git a/src/views/mfa/MFAStep-test.tsx b/src/views/mfa/MFAStep-test.tsx new file mode 100644 index 0000000000..81ec29ed30 --- /dev/null +++ b/src/views/mfa/MFAStep-test.tsx @@ -0,0 +1,166 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { of, throwError } from 'rxjs' + +import MFAStepComponent from 'src/views/mfa/MFAStep' +import { AnalyticEvents, defaultEventMetadata } from 'src/const/Analytics' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { member, institutionData, MFA_CREDENTIALS, initialState } from 'src/services/mockedData' +import { ReadableStatuses } from 'src/const/Statuses' +import { PostMessageContext } from 'src/ConnectWidget' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' + +const MFAStep = MFAStepComponent as unknown as React.ComponentType> + +type RenderMFAStepOptions = { + apiOverrides?: Partial + credentials?: typeof MFA_CREDENTIALS + enableSupportRequests?: boolean + onPostMessage?: ReturnType +} + +describe('MFAStep', () => { + const institution = institutionData.institution + + let onAnalyticsEvent: ReturnType + let onGoBack: ReturnType + + const createMember = (credentials: typeof MFA_CREDENTIALS = MFA_CREDENTIALS) => ({ + ...member.member, + connection_status: ReadableStatuses.CHALLENGED, + mfa: { credentials }, + }) + + beforeEach(() => { + onAnalyticsEvent = vi.fn() + onGoBack = vi.fn() + }) + + const renderMFAStep = ({ + apiOverrides = {}, + credentials = MFA_CREDENTIALS, + enableSupportRequests = true, + onPostMessage = vi.fn(), + }: RenderMFAStepOptions = {}) => { + const currentMember = createMember(credentials) + const utils = render( + + + , + { + onAnalyticsEvent, + apiValue: { ...apiValueMock, ...apiOverrides }, + preloadedState: { + ...initialState, + connect: { + ...initialState.connect, + members: [currentMember], + currentMemberGuid: currentMember.guid, + }, + }, + }, + ) + + return { ...utils, currentMember, onPostMessage } + } + + describe('Support Navigation', () => { + it('opens the support view and reports the analytics event when Get help is clicked', async () => { + const { user } = renderMFAStep() + + await user.click(await screen.findByRole('button', { name: 'Get help' })) + + expect(await screen.findByText('Request support')).toBeInTheDocument() + expect(onAnalyticsEvent).toHaveBeenCalledWith( + `connect_${AnalyticEvents.MFA_CLICKED_GET_HELP}`, + expect.objectContaining({ widgetType: defaultEventMetadata.widgetType }), + ) + }) + + it('does not render the Get help button when support is disabled', () => { + renderMFAStep({ enableSupportRequests: false }) + + expect(screen.queryByRole('button', { name: 'Get help' })).not.toBeInTheDocument() + }) + + it('returns to the MFA form when the support view is closed', async () => { + const { user } = renderMFAStep() + + await user.click(await screen.findByRole('button', { name: 'Get help' })) + expect(await screen.findByText('Request support')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /cancel/i })) + + expect(await screen.findByRole('button', { name: 'Get help' })).toBeInTheDocument() + expect(screen.queryByText('Request support')).not.toBeInTheDocument() + }) + }) + + describe('Error State', () => { + it('shows the error message and calls onGoBack when credentials are missing', async () => { + const { user } = renderMFAStep({ credentials: [] }) + + expect( + screen.getByText('Oops! Something went wrong. Please try again later.'), + ).toBeInTheDocument() + + await user.click(screen.getByText('Go Back')) + + expect(onGoBack).toHaveBeenCalled() + }) + }) + + describe('MFA Form Rendering', () => { + it('renders the MFA form and institution block when credentials are present', () => { + renderMFAStep() + + expect(screen.getByText(new RegExp(MFA_CREDENTIALS[0].label))).toBeInTheDocument() + expect(screen.getByTestId('continue-button')).toBeInTheDocument() + expect(screen.getByText(institution.name)).toBeInTheDocument() + }) + }) + + describe('MFA Form Submission', () => { + it('posts the submit message and calls the update API with the answer', async () => { + const updateMFA = vi.fn().mockReturnValue(of(createMember())) + const { user, currentMember, onPostMessage } = renderMFAStep({ apiOverrides: { updateMFA } }) + + await user.type(screen.getByRole('textbox'), 'myAnswer123') + await user.click(screen.getByTestId('continue-button')) + + expect(onPostMessage).toHaveBeenCalledWith('connect/submitMFA', { + member_guid: currentMember.guid, + }) + await waitFor(() => expect(updateMFA).toHaveBeenCalled()) + expect(updateMFA.mock.calls[0][0]).toEqual( + expect.objectContaining({ + credentials: [{ guid: MFA_CREDENTIALS[0].guid, value: 'myAnswer123' }], + }), + ) + }) + + it('keeps the continue button available after a failed submission', async () => { + const updateMFA = vi.fn().mockReturnValue(throwError(() => new Error('update failed'))) + const { user } = renderMFAStep({ apiOverrides: { updateMFA } }) + + await user.type(screen.getByRole('textbox'), 'test123') + await user.click(screen.getByTestId('continue-button')) + + expect(await screen.findByRole('button', { name: 'Continue' })).toBeInTheDocument() + }) + + it('shows the checking state while the submission is pending', async () => { + const updateMFA = vi.fn().mockReturnValue(new Promise(() => {})) + const { user } = renderMFAStep({ apiOverrides: { updateMFA } }) + + await user.type(screen.getByRole('textbox'), 'test123') + await user.click(screen.getByTestId('continue-button')) + + expect(await screen.findByText(/Checking/i)).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/mfa/__tests__/MFAStep-test.tsx b/src/views/mfa/__tests__/MFAStep-test.tsx deleted file mode 100644 index 605d2dd52b..0000000000 --- a/src/views/mfa/__tests__/MFAStep-test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' - -import MFAStep from 'src/views/mfa/MFAStep' -import { AnalyticEvents } from 'src/const/Analytics' -import { render, screen } from 'src/utilities/testingLibrary' - -const mockSendAnalyticsEvent = vi.fn() - -vi.mock('src/hooks/useAnalyticsEvent', () => { - return { default: () => mockSendAnalyticsEvent } -}) - -describe('MFAStep', () => { - const onGoBack = vi.fn() - const defaultProps = { - enableSupportRequests: true, - institution: { guid: 'INS-123' }, - onGoBack, - ref: React.createRef(), - } - - it('can navigate to Support when Support is enabled', async () => { - const { user } = render() - const supportButton = await screen.findByRole('button', { name: 'Get help' }) - - expect(supportButton).toBeInTheDocument() - - await user.click(supportButton) - expect(mockSendAnalyticsEvent).toHaveBeenCalledWith(AnalyticEvents.MFA_CLICKED_GET_HELP) - expect(await screen.findByText('Request support')).toBeInTheDocument() - }) - - it('does not render the support button when Support is disabled', async () => { - const noSupportProps = { - ...defaultProps, - enableSupportRequests: false, - } - render() - expect(screen.queryByRole('button', { name: 'Get help' })).not.toBeInTheDocument() - }) -}) diff --git a/src/views/microdeposits/AccountInfo-test.tsx b/src/views/microdeposits/AccountInfo-test.tsx new file mode 100644 index 0000000000..5a29b41e8f --- /dev/null +++ b/src/views/microdeposits/AccountInfo-test.tsx @@ -0,0 +1,215 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { AccountInfo } from 'src/views/microdeposits/AccountInfo' +import { Microdeposits as MicrodepositsComponent } from 'src/views/microdeposits/Microdeposits' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { AccountFields, ReadableAccountTypes } from 'src/views/microdeposits/const' + +const Microdeposits = MicrodepositsComponent as unknown as React.ComponentType< + Record +> + +interface AccountDetails { + account_type?: number + account_number?: string + routing_number?: string +} + +interface AccountInfoProps { + accountDetails?: AccountDetails + focus?: string + onContinue: (details: AccountDetails) => void +} + +describe('AccountInfo', () => { + const renderAccountInfo = (props: Partial = {}) => + render( {}} {...props} />) + + const renderAccountInfoStep = async () => { + const utils = render(, { + apiValue: { ...apiValueMock, verifyRoutingNumber: () => Promise.resolve({}) }, + }) + + await utils.user.type(await screen.findByTestId('routing-number-input'), '123456789') + await utils.user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await utils.user.click(await screen.findByRole('button', { name: 'Continue' })) + + await screen.findByText('Enter account information') + + return utils + } + + describe('Rendering', () => { + it('renders the account information form', () => { + renderAccountInfo() + + expect(screen.getByText('Enter account information')).toBeInTheDocument() + expect(screen.getByText('Account type')).toBeInTheDocument() + expect(screen.getByText('Checking')).toBeInTheDocument() + expect(screen.getByText('Savings')).toBeInTheDocument() + expect(screen.getByLabelText('Account number *')).toBeInTheDocument() + expect(screen.getByLabelText('Confirm account number *')).toBeInTheDocument() + expect(screen.getByText('Required')).toBeInTheDocument() + expect(screen.getByText('Help finding your account number')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Continue to confirm details' }), + ).toBeInTheDocument() + }) + + it('defaults to the checking account type', () => { + renderAccountInfo() + + expect(screen.getByRole('radio', { name: 'Checking' })).toBeChecked() + expect(screen.getByRole('radio', { name: 'Savings' })).not.toBeChecked() + }) + + it('pre-populates the account type from the provided details', () => { + renderAccountInfo({ accountDetails: { account_type: ReadableAccountTypes.SAVINGS } }) + + expect(screen.getByRole('radio', { name: 'Savings' })).toBeChecked() + }) + + it('pre-populates both account number fields from the provided details', () => { + renderAccountInfo({ accountDetails: { account_number: '123456789' } }) + + expect(screen.getByTestId('account-number-input')).toHaveValue('123456789') + expect(screen.getByTestId('confirm-account-number-input')).toHaveValue('123456789') + }) + }) + + describe('Focus', () => { + it('focuses the account number field when requested', () => { + renderAccountInfo({ focus: AccountFields.ACCOUNT_NUMBER }) + + expect(screen.getByTestId('account-number-input')).toHaveFocus() + }) + + it('focuses the checking option when requested', () => { + renderAccountInfo({ focus: AccountFields.ACCOUNT_TYPE }) + + expect(screen.getByRole('radio', { name: 'Checking' })).toHaveFocus() + }) + + it('focuses the savings option when it is the pre-selected type', () => { + renderAccountInfo({ + accountDetails: { account_type: ReadableAccountTypes.SAVINGS }, + focus: AccountFields.ACCOUNT_TYPE, + }) + + expect(screen.getByRole('radio', { name: 'Savings' })).toHaveFocus() + }) + }) + + describe('Account type selection', () => { + it('lets the user switch between checking and savings', async () => { + const { user } = renderAccountInfo() + + const checkingOption = screen.getByRole('radio', { name: 'Checking' }) + const savingsOption = screen.getByRole('radio', { name: 'Savings' }) + + await user.click(savingsOption) + expect(savingsOption).toBeChecked() + + await user.click(checkingOption) + expect(checkingOption).toBeChecked() + }) + }) + + describe('Validation', () => { + it('shows required-field errors when the account numbers are missing', async () => { + const { user } = renderAccountInfo() + + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + expect((await screen.findAllByText('Account number is required'))[0]).toBeInTheDocument() + expect(screen.getAllByText('Confirm account number is required')[0]).toBeInTheDocument() + }) + + it('shows an error when the account number contains non-digit characters', async () => { + const { user } = renderAccountInfo() + + await user.type(screen.getByTestId('account-number-input'), '12345abc') + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + expect( + (await screen.findAllByText('Account number must only contain digits'))[0], + ).toBeInTheDocument() + }) + + it('shows a mismatch error and clears it once the numbers match', async () => { + const { user } = renderAccountInfo() + + await user.type(screen.getByTestId('account-number-input'), '123456789') + await user.type(screen.getByTestId('confirm-account-number-input'), '987654321') + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + expect( + ( + await screen.findAllByText('Account number must be the same as Confirm account number') + )[0], + ).toBeInTheDocument() + + await user.clear(screen.getByTestId('confirm-account-number-input')) + await user.type(screen.getByTestId('confirm-account-number-input'), '123456789') + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await waitFor(() => + expect( + screen.queryByText('Account number must be the same as Confirm account number'), + ).not.toBeInTheDocument(), + ) + }) + }) + + describe('Submission', () => { + it('advances to the account holder step after a valid submission', async () => { + const { user } = await renderAccountInfoStep() + + await user.type(screen.getByTestId('account-number-input'), '123456789') + await user.type(screen.getByTestId('confirm-account-number-input'), '123456789') + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + expect(await screen.findByText('Enter account holder information')).toBeInTheDocument() + }) + + it('advances to the account holder step when the user presses Enter', async () => { + const { user } = await renderAccountInfoStep() + + await user.type(screen.getByTestId('account-number-input'), '123456789') + await user.type(screen.getByTestId('confirm-account-number-input'), '123456789{Enter}') + + expect(await screen.findByText('Enter account holder information')).toBeInTheDocument() + }) + }) + + describe('Help finding your account number', () => { + it('opens the help view and returns to the form when closed', async () => { + const { user } = renderAccountInfo() + + await user.click(screen.getByText('Help finding your account number')) + + expect(await screen.findByText('Find your account number')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByText('Enter account information')).toBeInTheDocument() + }) + }) + + describe('Accessibility', () => { + it('announces validation errors to screen readers', async () => { + const { user } = renderAccountInfo() + + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await waitFor(() => { + const ariaLive = document.querySelector('[aria-live="assertive"]') + expect(ariaLive?.textContent).toContain('Account number is required') + expect(ariaLive?.textContent).toContain('Confirm account number is required') + }) + }) + }) +}) diff --git a/src/views/microdeposits/ComeBack-test.tsx b/src/views/microdeposits/ComeBack-test.tsx new file mode 100644 index 0000000000..fd49b6b1f0 --- /dev/null +++ b/src/views/microdeposits/ComeBack-test.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import RenderConnectStep from 'src/components/RenderConnectStep' +import { render, screen } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { initialState } from 'src/services/mockedData' +import { MicrodepositsStatuses } from 'src/views/microdeposits/const' +import { STEPS, VERIFY_MODE } from 'src/const/Connect' + +describe('ComeBack', () => { + const microdeposit = { + guid: 'MD-123', + account_name: 'My Checking Account', + status: MicrodepositsStatuses.REQUESTED, + } + + const connectStepProps = { + availableAccountTypes: [], + handleConsentGoBack: () => {}, + handleCredentialsGoBack: () => {}, + navigationRef: React.createRef(), + onManualAccountAdded: () => {}, + onUpsertMember: () => {}, + setConnectLocalState: () => {}, + } + + const microdepositsEnabledState = (currentMicrodepositGuid: string) => ({ + ...initialState, + config: { ...initialState.config, mode: VERIFY_MODE }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.MICRODEPOSITS }], + currentMicrodepositGuid, + }, + profiles: { + ...initialState.profiles, + clientProfile: { + ...initialState.profiles.clientProfile, + account_verification_is_enabled: true, + is_microdeposits_enabled: true, + }, + widgetProfile: { + ...initialState.profiles.widgetProfile, + show_microdeposits_in_connect: true, + }, + }, + }) + + const renderComeBackStep = () => + render(, { + apiValue: { + ...apiValueMock, + loadMicrodepositByGuid: vi + .fn() + .mockResolvedValueOnce({ ...microdeposit, status: MicrodepositsStatuses.PREINITIATED }) + .mockResolvedValue(microdeposit), + refreshMicrodepositStatus: vi.fn().mockResolvedValue(undefined), + } as unknown as typeof apiValueMock, + preloadedState: microdepositsEnabledState(microdeposit.guid), + }) + + it('shows the come-back message once the microdeposit loads', async () => { + renderComeBackStep() + + expect(await screen.findByText('Check back soon', {}, { timeout: 4000 })).toBeInTheDocument() + expect(screen.getByTestId('thanks-paragraph')).toHaveTextContent('My Checking Account') + expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument() + }) + + it('returns the user to institution search when Done is clicked', async () => { + const { user } = renderComeBackStep() + + const doneButton = await screen.findByRole('button', { name: 'Done' }, { timeout: 4000 }) + await user.click(doneButton) + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + }) +}) diff --git a/src/views/microdeposits/ConfirmDetails-test.tsx b/src/views/microdeposits/ConfirmDetails-test.tsx new file mode 100644 index 0000000000..836b796972 --- /dev/null +++ b/src/views/microdeposits/ConfirmDetails-test.tsx @@ -0,0 +1,291 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { of, throwError, delay } from 'rxjs' + +import { ConfirmDetails } from 'src/views/microdeposits/ConfirmDetails' +import { Microdeposits as MicrodepositsComponent } from 'src/views/microdeposits/Microdeposits' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { MicrodepositsStatuses, ReadableAccountTypes } from 'src/views/microdeposits/const' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' + +const Microdeposits = MicrodepositsComponent as unknown as React.ComponentType< + Record +> + +interface AccountDetails { + routing_number: string + account_type: number + account_number: string + first_name?: string + last_name?: string + email?: string +} + +interface Microdeposit { + guid?: string + account_name?: string + status?: string +} + +interface ConfirmDetailsProps { + accountDetails: AccountDetails + currentMicrodeposit: Microdeposit + onEditForm: (field: string) => void + onError: (error: unknown) => void + onSuccess: (microdeposit: Microdeposit) => void + shouldShowUserDetails?: boolean +} + +const enteredDetails = { + routingNumber: '123456789', + accountNumber: '9876543210', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', +} + +type ApiOverrides = Record + +describe('ConfirmDetails', () => { + const leafAccountDetails: AccountDetails = { + routing_number: '123456789', + account_type: ReadableAccountTypes.CHECKING, + account_number: '9876543210', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + } + + const renderConfirmDetails = (props: Partial = {}) => + render( + {}} + onError={() => {}} + onSuccess={() => {}} + shouldShowUserDetails={false} + {...props} + />, + ) + + const renderMicrodeposits = (apiOverrides: ApiOverrides = {}) => + render(, { + apiValue: { + ...apiValueMock, + verifyRoutingNumber: () => Promise.resolve({}), + ...apiOverrides, + } as unknown as typeof apiValueMock, + }) + + const navigateToConfirmDetails = async ( + user: ReturnType['user'], + { includeUserDetails = true } = {}, + ) => { + await user.type( + await screen.findByTestId('routing-number-input', {}, { timeout: 4000 }), + enteredDetails.routingNumber, + ) + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await user.click(await screen.findByRole('button', { name: 'Continue' })) + + await user.type(await screen.findByTestId('account-number-input'), enteredDetails.accountNumber) + await user.type( + screen.getByTestId('confirm-account-number-input'), + enteredDetails.accountNumber, + ) + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + if (includeUserDetails) { + await user.type(await screen.findByTestId('first-name-input'), enteredDetails.firstName) + await user.type(screen.getByTestId('last-name-input'), enteredDetails.lastName) + await user.type(screen.getByTestId('email-input'), enteredDetails.email) + await user.click(screen.getByRole('button', { name: 'Continue to account details' })) + } + + await screen.findByText('Review your information') + } + + describe('Initial Rendering', () => { + it('renders the account details for review with a confirm action and disclaimer', () => { + renderConfirmDetails() + + expect(screen.getByText('Review your information')).toBeInTheDocument() + expect(screen.getByText('Routing number')).toBeInTheDocument() + expect(screen.getByText('123456789')).toBeInTheDocument() + expect(screen.getByText('Account type')).toBeInTheDocument() + expect(screen.getByText('Checking')).toBeInTheDocument() + expect(screen.getByText('Account number')).toBeInTheDocument() + expect(screen.getByText('9876543210')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument() + + const disclaimer = screen.getByTestId('disclaimer-paragraph') + expect(disclaimer).toHaveTextContent(/By clicking Confirm, I authorize/) + expect(disclaimer).toHaveTextContent(/Dwolla, Inc./) + expect(disclaimer).toHaveTextContent(/micro-deposit verification/) + + expect(screen.queryByText('First and last name')).not.toBeInTheDocument() + expect(screen.queryByText('Email')).not.toBeInTheDocument() + }) + + it('displays savings account type when provided', () => { + renderConfirmDetails({ + accountDetails: { ...leafAccountDetails, account_type: ReadableAccountTypes.SAVINGS }, + }) + + expect(screen.getByText('Savings')).toBeInTheDocument() + }) + + it('renders user details when shouldShowUserDetails is true', () => { + renderConfirmDetails({ shouldShowUserDetails: true }) + + expect(screen.getByText('First and last name')).toBeInTheDocument() + expect(screen.getByText('John Doe')).toBeInTheDocument() + expect(screen.getByText('Email')).toBeInTheDocument() + expect(screen.getByText('john.doe@example.com')).toBeInTheDocument() + }) + }) + + describe('Edit Button Functionality', () => { + it('returns to the routing number step when routing number edit is clicked', async () => { + const { user } = renderMicrodeposits() + await navigateToConfirmDetails(user) + + await user.click(screen.getByRole('button', { name: 'Edit routing number' })) + + expect(await screen.findByText('Enter routing number')).toBeInTheDocument() + }) + + it('returns to the account information step when an account field edit is clicked', async () => { + const { user } = renderMicrodeposits() + await navigateToConfirmDetails(user) + + await user.click(screen.getByRole('button', { name: 'Edit account number' })) + + expect(await screen.findByText('Enter account information')).toBeInTheDocument() + }) + + it('returns to the personal information step when a user detail edit is clicked', async () => { + const { user } = renderMicrodeposits() + await navigateToConfirmDetails(user) + + await user.click(screen.getByRole('button', { name: 'Edit first and last name' })) + + expect(await screen.findByText('Enter account holder information')).toBeInTheDocument() + }) + }) + + describe('Form Submission - Create New Microdeposit', () => { + it('creates the microdeposit and advances to the come-back step', async () => { + const createMicrodeposit = vi.fn(() => of({ micro_deposit: { guid: 'MD-123' } })) + const { user } = renderMicrodeposits({ createMicrodeposit }) + + await navigateToConfirmDetails(user) + await user.click(screen.getByRole('button', { name: 'Confirm' })) + + await waitFor(() => + expect(createMicrodeposit).toHaveBeenCalledWith({ + routing_number: '123456789', + account_type: ReadableAccountTypes.CHECKING, + account_number: '9876543210', + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + account_name: 'Checking ...3210', + user_guid: 'USR-123', + }), + ) + + expect(await screen.findByText('Check back soon', {}, { timeout: 4000 })).toBeInTheDocument() + }) + + it('shows a sending state while the microdeposit is being created', async () => { + const createMicrodeposit = vi.fn(() => + of({ micro_deposit: { guid: 'MD-123' } }).pipe(delay(100)), + ) + const { user } = renderMicrodeposits({ createMicrodeposit }) + + await navigateToConfirmDetails(user) + await user.click(screen.getByRole('button', { name: 'Confirm' })) + + const sendingButton = await screen.findByRole('button', { name: 'Sending...' }) + expect(sendingButton).toBeDisabled() + }) + }) + + describe('Form Submission - Update Existing Microdeposit', () => { + it('updates the existing microdeposit and advances to the come-back step', async () => { + const existingMicrodeposit = { + guid: 'MD-EXISTING', + status: MicrodepositsStatuses.PREINITIATED, + first_name: 'John', + last_name: 'Doe', + email: 'john.doe@example.com', + } + const loadMicrodepositByGuid = vi + .fn() + .mockResolvedValueOnce({ ...existingMicrodeposit, status: MicrodepositsStatuses.INITIATED }) + .mockResolvedValue(existingMicrodeposit) + const updateMicrodeposit = vi.fn(() => of({ micro_deposit: { guid: 'MD-EXISTING' } })) + + const { user } = render( + , + { + apiValue: { + ...apiValueMock, + verifyRoutingNumber: () => Promise.resolve({}), + refreshMicrodepositStatus: vi.fn().mockResolvedValue(undefined), + loadMicrodepositByGuid, + updateMicrodeposit, + } as unknown as typeof apiValueMock, + }, + ) + + await navigateToConfirmDetails(user, { includeUserDetails: false }) + await user.click(screen.getByRole('button', { name: 'Confirm' })) + + await waitFor(() => + expect(updateMicrodeposit).toHaveBeenCalledWith('MD-EXISTING', { + account_name: 'Checking ...3210', + account_number: '9876543210', + account_type: ReadableAccountTypes.CHECKING, + routing_number: '123456789', + user_guid: 'USR-123', + }), + ) + + expect(await screen.findByText('Check back soon', {}, { timeout: 4000 })).toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('shows the error screen when creating the microdeposit fails', async () => { + const createMicrodeposit = vi.fn(() => + throwError(() => ({ + response: { + status: 400, + data: { + micro_deposit: { + account_number: '9876543210', + routing_number: '123456789', + account_type: ReadableAccountTypes.CHECKING, + }, + }, + }, + })), + ) + const { user } = renderMicrodeposits({ createMicrodeposit }) + + await navigateToConfirmDetails(user) + await user.click(screen.getByRole('button', { name: 'Confirm' })) + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument() + expect( + screen.getByText( + 'We’re unable to connect this account. Please review the account details you submitted.', + ), + ).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/microdeposits/HowItWorks-test.tsx b/src/views/microdeposits/HowItWorks-test.tsx new file mode 100644 index 0000000000..ffd91b5ada --- /dev/null +++ b/src/views/microdeposits/HowItWorks-test.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { describe, expect, it } from 'vitest' + +import { Microdeposits as MicrodepositsComponent } from 'src/views/microdeposits/Microdeposits' +import { render, screen } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' + +const Microdeposits = MicrodepositsComponent as unknown as React.ComponentType< + Record +> + +describe('HowItWorks', () => { + const renderHowItWorksStep = async () => { + const utils = render( {}} />, { + apiValue: { ...apiValueMock, verifyRoutingNumber: () => Promise.resolve({}) }, + }) + + await utils.user.type(await screen.findByTestId('routing-number-input'), '123456789') + await utils.user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await screen.findByText('Connect your institution with account numbers') + + return utils + } + + describe('Initial Rendering', () => { + it('renders the how-it-works header, instructions, and continue button', async () => { + await renderHowItWorksStep() + + expect( + screen.getByRole('heading', { name: 'Connect your institution with account numbers' }), + ).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument() + + const instructions = screen.getAllByRole('listitem') + expect(instructions).toHaveLength(3) + expect(instructions[0]).toHaveTextContent('Enter your account information.') + expect(instructions[1]).toHaveTextContent("You'll receive two small deposits.") + expect(instructions[2]).toHaveTextContent('Return to verify the deposit amounts.') + }) + }) + + describe('Continue Button Interaction', () => { + it('advances to the account information step when continue is clicked', async () => { + const { user } = await renderHowItWorksStep() + + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByText('Enter account information')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/microdeposits/MicrodepositErrors-test.tsx b/src/views/microdeposits/MicrodepositErrors-test.tsx new file mode 100644 index 0000000000..95b79483e7 --- /dev/null +++ b/src/views/microdeposits/MicrodepositErrors-test.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' +import { throwError } from 'rxjs' + +import RenderConnectStep from 'src/components/RenderConnectStep' +import { render, screen } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { initialState } from 'src/services/mockedData' +import { MicrodepositsStatuses, ReadableAccountTypes } from 'src/views/microdeposits/const' +import { STEPS, VERIFY_MODE } from 'src/const/Connect' + +interface Microdeposit { + guid?: string + status?: number + account_number?: string + routing_number?: string + account_type?: number +} + +type ApiOverrides = Record + +const enteredDetails = { + routingNumber: '123456789', + accountNumber: '9876543210', + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@example.com', +} + +describe('MicrodepositErrors', () => { + const erroredMicrodeposit: Microdeposit = { + guid: 'MD-123', + status: MicrodepositsStatuses.ERRORED, + account_number: '9876543210', + routing_number: '123456789', + account_type: ReadableAccountTypes.CHECKING, + } + + const connectStepProps = { + availableAccountTypes: [], + handleConsentGoBack: () => {}, + handleCredentialsGoBack: () => {}, + navigationRef: React.createRef(), + onManualAccountAdded: () => {}, + onUpsertMember: () => {}, + setConnectLocalState: () => {}, + } + + const microdepositsEnabledState = (currentMicrodepositGuid: string | null) => ({ + ...initialState, + config: { ...initialState.config, mode: VERIFY_MODE }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.MICRODEPOSITS }], + currentMicrodepositGuid, + }, + profiles: { + ...initialState.profiles, + clientProfile: { + ...initialState.profiles.clientProfile, + account_verification_is_enabled: true, + is_microdeposits_enabled: true, + }, + widgetProfile: { + ...initialState.profiles.widgetProfile, + show_microdeposits_in_connect: true, + }, + }, + }) + + const renderErrorsStep = (microdeposit: Microdeposit, apiOverrides: ApiOverrides = {}) => + render(, { + apiValue: { + ...apiValueMock, + loadMicrodepositByGuid: vi + .fn() + .mockResolvedValueOnce({ ...microdeposit, status: MicrodepositsStatuses.PREINITIATED }) + .mockResolvedValue(microdeposit), + refreshMicrodepositStatus: vi.fn().mockResolvedValue(undefined), + ...apiOverrides, + } as unknown as typeof apiValueMock, + preloadedState: microdepositsEnabledState(microdeposit.guid ?? null), + }) + + const renderMicrodepositsForm = (apiOverrides: ApiOverrides = {}) => + render(, { + apiValue: { + ...apiValueMock, + verifyRoutingNumber: () => Promise.resolve({}), + ...apiOverrides, + } as unknown as typeof apiValueMock, + preloadedState: microdepositsEnabledState(null), + }) + + const navigateToConfirm = async (user: ReturnType['user']) => { + await user.type(await screen.findByTestId('routing-number-input'), enteredDetails.routingNumber) + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await user.click(await screen.findByRole('button', { name: 'Continue' })) + + await user.type(await screen.findByTestId('account-number-input'), enteredDetails.accountNumber) + await user.type( + screen.getByTestId('confirm-account-number-input'), + enteredDetails.accountNumber, + ) + await user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await user.type(await screen.findByTestId('first-name-input'), enteredDetails.firstName) + await user.type(screen.getByTestId('last-name-input'), enteredDetails.lastName) + await user.type(screen.getByTestId('email-input'), enteredDetails.email) + await user.click(screen.getByRole('button', { name: 'Continue to account details' })) + + await screen.findByText('Review your information') + } + + describe('Prevented status', () => { + it('shows the account-not-connected error and returns the user to search', async () => { + const { user } = renderErrorsStep({ + ...erroredMicrodeposit, + status: MicrodepositsStatuses.PREVENTED, + }) + + expect( + await screen.findByText('Account not connected', {}, { timeout: 4000 }), + ).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('too many failed attempts') + expect(screen.queryByRole('button', { name: 'Edit details' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + }) + }) + + describe('Rejected status', () => { + it('shows the try-again-later error and returns the user to search', async () => { + const { user } = renderErrorsStep({ + ...erroredMicrodeposit, + status: MicrodepositsStatuses.REJECTED, + }) + + expect( + await screen.findByText('Something went wrong', {}, { timeout: 4000 }), + ).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('Please try again later') + + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + }) + }) + + describe('Errored status', () => { + it('shows the review-details error and lets the user edit their details', async () => { + const { user } = renderErrorsStep(erroredMicrodeposit) + + expect( + await screen.findByText('Something went wrong', {}, { timeout: 4000 }), + ).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('Please review the account details') + + await user.click(screen.getByRole('button', { name: 'Edit details' })) + + expect(await screen.findByText('Enter routing number')).toBeInTheDocument() + }) + + it('lets the user connect a different account and returns them to search', async () => { + const { user } = renderErrorsStep(erroredMicrodeposit) + + await screen.findByText('Something went wrong', {}, { timeout: 4000 }) + + await user.click(screen.getByRole('button', { name: 'Connect a different account' })) + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + }) + + it('displays the submitted account information', async () => { + renderErrorsStep(erroredMicrodeposit) + + await screen.findByText('Something went wrong', {}, { timeout: 4000 }) + + expect(screen.getByText('Account type')).toBeInTheDocument() + expect(screen.getByText('Checking')).toBeInTheDocument() + expect(screen.getByText('Routing number')).toBeInTheDocument() + expect(screen.getByText('123456789')).toBeInTheDocument() + expect(screen.getByText('Account number')).toBeInTheDocument() + expect(screen.getByText('•••• 3210')).toBeInTheDocument() + }) + + it('displays a savings account type', async () => { + renderErrorsStep({ ...erroredMicrodeposit, account_type: ReadableAccountTypes.SAVINGS }) + + await screen.findByText('Something went wrong', {}, { timeout: 4000 }) + + expect(screen.getByText('Savings')).toBeInTheDocument() + }) + + it('displays a dash when account information is missing', async () => { + renderErrorsStep({ guid: 'MD-123', status: MicrodepositsStatuses.ERRORED }) + + await screen.findByText('Something went wrong', {}, { timeout: 4000 }) + + const dashes = screen.getAllByText('-') + expect(dashes.length).toBeGreaterThanOrEqual(3) + }) + }) + + describe('Create failure', () => { + it('shows the review-details error using the details returned by the failed request', async () => { + const createMicrodeposit = vi.fn(() => + throwError(() => ({ + response: { + status: 400, + data: { + micro_deposit: { + account_number: '1111222233334444', + routing_number: '555666777', + account_type: ReadableAccountTypes.SAVINGS, + }, + }, + }, + })), + ) + const { user } = renderMicrodepositsForm({ createMicrodeposit }) + + await navigateToConfirm(user) + await user.click(screen.getByRole('button', { name: 'Confirm' })) + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('Please review the account details') + expect(screen.getByRole('button', { name: 'Edit details' })).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Connect a different account' }), + ).toBeInTheDocument() + + expect(screen.getByText('•••• 4444')).toBeInTheDocument() + expect(screen.getByText('555666777')).toBeInTheDocument() + expect(screen.getByText('Savings')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/microdeposits/Microdeposits-test.tsx b/src/views/microdeposits/Microdeposits-test.tsx new file mode 100644 index 0000000000..93d2f56d74 --- /dev/null +++ b/src/views/microdeposits/Microdeposits-test.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { Microdeposits as MicrodepositsComponent } from 'src/views/microdeposits/Microdeposits' +import { render, screen } from 'src/utilities/testingLibrary' +import { MicrodepositsStatuses } from 'src/views/microdeposits/const' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' + +const Microdeposits = MicrodepositsComponent as unknown as React.ComponentType< + Record +> + +interface Microdeposit { + guid?: string + status?: number + account_number?: string + routing_number?: string + account_type?: number + first_name?: string + last_name?: string + email?: string +} + +type ApiOverrides = Record + +describe('Microdeposits', () => { + const microdeposit = (overrides: Partial = {}): Microdeposit => ({ + guid: 'MD-123', + status: MicrodepositsStatuses.INITIATED, + account_number: '1234567890', + routing_number: '987654321', + account_type: 0, + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + ...overrides, + }) + + const renderWithoutGuid = (apiOverrides: ApiOverrides = {}) => + render( {}} />, { + apiValue: { ...apiValueMock, ...apiOverrides } as unknown as typeof apiValueMock, + }) + + const renderWithGuid = (status: number, apiOverrides: ApiOverrides = {}) => + render( {}} />, { + apiValue: { + ...apiValueMock, + loadMicrodepositByGuid: vi + .fn() + .mockResolvedValueOnce(microdeposit({ status: MicrodepositsStatuses.PREINITIATED })) + .mockResolvedValue(microdeposit({ status })), + refreshMicrodepositStatus: vi.fn().mockResolvedValue(undefined), + ...apiOverrides, + } as unknown as typeof apiValueMock, + }) + + describe('Without a microdeposit guid', () => { + it('starts the user on the routing number step', async () => { + renderWithoutGuid() + + expect(await screen.findByText('Enter routing number')).toBeInTheDocument() + expect(screen.getByText('Private and secure')).toBeInTheDocument() + }) + }) + + describe('Resuming an existing microdeposit by guid', () => { + it('resumes on the come-back step for an initiated microdeposit', async () => { + renderWithGuid(MicrodepositsStatuses.INITIATED) + + expect(await screen.findByText('Check back soon', {}, { timeout: 4000 })).toBeInTheDocument() + }) + + it('resumes on the verify-deposits step once deposits have landed', async () => { + renderWithGuid(MicrodepositsStatuses.DEPOSITED) + + expect( + await screen.findByText('Enter deposit amounts', {}, { timeout: 4000 }), + ).toBeInTheDocument() + }) + + it('resumes on the verified step for a verified microdeposit', async () => { + renderWithGuid(MicrodepositsStatuses.VERIFIED) + + expect( + await screen.findByText('Deposits verified', {}, { timeout: 4000 }), + ).toBeInTheDocument() + }) + + it('resumes on the error step for an errored microdeposit', async () => { + renderWithGuid(MicrodepositsStatuses.ERRORED) + + expect( + await screen.findByText('Something went wrong', {}, { timeout: 4000 }), + ).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/microdeposits/PersonalInfoForm-test.tsx b/src/views/microdeposits/PersonalInfoForm-test.tsx new file mode 100644 index 0000000000..997a1aaa3d --- /dev/null +++ b/src/views/microdeposits/PersonalInfoForm-test.tsx @@ -0,0 +1,192 @@ +import React from 'react' +import { describe, expect, it } from 'vitest' + +import { PersonalInfoForm } from 'src/views/microdeposits/PersonalInfoForm' +import { Microdeposits as MicrodepositsComponent } from 'src/views/microdeposits/Microdeposits' +import { render, screen } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' + +const Microdeposits = MicrodepositsComponent as unknown as React.ComponentType< + Record +> + +interface AccountDetails { + first_name?: string + last_name?: string + email?: string +} + +interface PersonalInfoFormProps { + accountDetails?: AccountDetails +} + +describe('PersonalInfoForm', () => { + const renderPersonalInfoForm = (props: PersonalInfoFormProps = {}) => + render( {}} {...props} />) + + const renderPersonalInfoFormStep = async () => { + const utils = render( {}} />, { + apiValue: { ...apiValueMock, verifyRoutingNumber: () => Promise.resolve({}) }, + }) + + await utils.user.type(await screen.findByTestId('routing-number-input'), '123456789') + await utils.user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await utils.user.click(await screen.findByRole('button', { name: 'Continue' })) + + await utils.user.type(await screen.findByTestId('account-number-input'), '123456789') + await utils.user.type(screen.getByTestId('confirm-account-number-input'), '123456789') + await utils.user.click(screen.getByRole('button', { name: 'Continue to confirm details' })) + + await screen.findByText('Enter account holder information') + + return utils + } + + describe('Rendering', () => { + it('renders the header, helper text, required fields, and continue button', () => { + renderPersonalInfoForm() + + expect( + screen.getByRole('heading', { name: 'Enter account holder information' }), + ).toBeInTheDocument() + expect( + screen.getByText( + 'This helps verify account ownership, and should match the first and last name on this account.', + ), + ).toBeInTheDocument() + + expect(screen.getByLabelText('First name *')).toBeRequired() + expect(screen.getByLabelText('Last name *')).toBeRequired() + expect(screen.getByLabelText('Email *')).toBeRequired() + + expect( + screen.getByRole('button', { name: 'Continue to account details' }), + ).toBeInTheDocument() + expect(screen.getByText('Required')).toBeInTheDocument() + + expect(screen.getByTestId('first-name-input')).toHaveFocus() + expect(screen.getByTestId('first-name-input')).toHaveValue('') + expect(screen.getByTestId('last-name-input')).toHaveValue('') + expect(screen.getByTestId('email-input')).toHaveValue('') + }) + }) + + describe('Pre-populated values', () => { + it('pre-fills each field from the provided account details', () => { + renderPersonalInfoForm({ + accountDetails: { + first_name: 'Jane', + last_name: 'Smith', + email: 'jane.smith@example.com', + }, + }) + + expect(screen.getByTestId('first-name-input')).toHaveValue('Jane') + expect(screen.getByTestId('last-name-input')).toHaveValue('Smith') + expect(screen.getByTestId('email-input')).toHaveValue('jane.smith@example.com') + }) + }) + + describe('Form input', () => { + it('updates each field as the user types', async () => { + const { user } = renderPersonalInfoForm() + + await user.type(screen.getByTestId('first-name-input'), 'Alice') + await user.type(screen.getByTestId('last-name-input'), 'Johnson') + await user.type(screen.getByTestId('email-input'), 'alice@test.com') + + expect(screen.getByTestId('first-name-input')).toHaveValue('Alice') + expect(screen.getByTestId('last-name-input')).toHaveValue('Johnson') + expect(screen.getByTestId('email-input')).toHaveValue('alice@test.com') + }) + + it('lets the user clear a pre-filled value', async () => { + const { user } = renderPersonalInfoForm({ accountDetails: { first_name: 'John' } }) + + await user.clear(screen.getByTestId('first-name-input')) + + expect(screen.getByTestId('first-name-input')).toHaveValue('') + }) + }) + + describe('Validation', () => { + it('flags every empty required field on submit', async () => { + const { user } = renderPersonalInfoForm() + + await user.click(screen.getByRole('button', { name: 'Continue to account details' })) + + expect(await screen.findByTestId('first-name-input')).toHaveAttribute( + 'aria-invalid', + 'First name is required', + ) + expect(screen.getByTestId('last-name-input')).toHaveAttribute( + 'aria-invalid', + 'Last name is required', + ) + expect(screen.getByTestId('email-input')).toHaveAttribute('aria-invalid', 'true') + }) + + it('flags an email with an invalid format', async () => { + const { user } = renderPersonalInfoForm() + + await user.type(screen.getByTestId('first-name-input'), 'John') + await user.type(screen.getByTestId('last-name-input'), 'Doe') + await user.type(screen.getByTestId('email-input'), 'invalid-email') + await user.click(screen.getByRole('button', { name: 'Continue to account details' })) + + expect(await screen.findByTestId('email-input')).toHaveAttribute('aria-invalid', 'true') + }) + + it('announces validation errors to screen readers', async () => { + const { user } = renderPersonalInfoForm() + + await user.click(screen.getByRole('button', { name: 'Continue to account details' })) + + await screen.findByTestId('first-name-input') + const ariaLive = document.querySelector('[aria-live="assertive"]') + expect(ariaLive?.textContent).toContain('First name is required') + expect(ariaLive?.textContent).toContain('Last name is required') + expect(ariaLive?.textContent).toContain('Email is required') + }) + }) + + describe('Submission', () => { + it('advances to the review step with the submitted details', async () => { + const { user } = await renderPersonalInfoFormStep() + + await user.type(screen.getByTestId('first-name-input'), 'Alice') + await user.type(screen.getByTestId('last-name-input'), 'Johnson') + await user.type(screen.getByTestId('email-input'), 'alice.johnson@example.com') + await user.click(screen.getByRole('button', { name: 'Continue to account details' })) + + expect(await screen.findByText('Review your information')).toBeInTheDocument() + expect(screen.getByText('Alice Johnson')).toBeInTheDocument() + expect(screen.getByText('alice.johnson@example.com')).toBeInTheDocument() + }) + + it('accepts complex but valid email formats', async () => { + const { user } = await renderPersonalInfoFormStep() + + await user.type(screen.getByTestId('first-name-input'), 'Test') + await user.type(screen.getByTestId('last-name-input'), 'User') + await user.type(screen.getByTestId('email-input'), 'test.user+tag@example.co.uk') + await user.click(screen.getByRole('button', { name: 'Continue to account details' })) + + expect(await screen.findByText('Review your information')).toBeInTheDocument() + expect(screen.getByText('test.user+tag@example.co.uk')).toBeInTheDocument() + }) + + it('stays on the form when submitted with validation errors', async () => { + const { user } = await renderPersonalInfoFormStep() + + await user.click(screen.getByRole('button', { name: 'Continue to account details' })) + + expect(await screen.findByTestId('first-name-input')).toHaveAttribute( + 'aria-invalid', + 'First name is required', + ) + expect(screen.getByText('Enter account holder information')).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/microdeposits/Verified-test.tsx b/src/views/microdeposits/Verified-test.tsx new file mode 100644 index 0000000000..5f0671b915 --- /dev/null +++ b/src/views/microdeposits/Verified-test.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import RenderConnectStep from 'src/components/RenderConnectStep' +import { Verified } from 'src/views/microdeposits/Verified' +import { render, screen } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { initialState } from 'src/services/mockedData' +import { MicrodepositsStatuses } from 'src/views/microdeposits/const' +import { STEPS, VERIFY_MODE } from 'src/const/Connect' + +describe('Verified', () => { + const renderVerified = () => + render( {}} />) + + const connectStepProps = { + availableAccountTypes: [], + handleConsentGoBack: () => {}, + handleCredentialsGoBack: () => {}, + navigationRef: React.createRef(), + onManualAccountAdded: () => {}, + onUpsertMember: () => {}, + setConnectLocalState: () => {}, + } + + const microdepositsEnabledState = (currentMicrodepositGuid: string) => ({ + ...initialState, + config: { ...initialState.config, mode: VERIFY_MODE }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.MICRODEPOSITS }], + currentMicrodepositGuid, + }, + profiles: { + ...initialState.profiles, + clientProfile: { + ...initialState.profiles.clientProfile, + account_verification_is_enabled: true, + is_microdeposits_enabled: true, + }, + widgetProfile: { + ...initialState.profiles.widgetProfile, + show_microdeposits_in_connect: true, + }, + }, + }) + + const renderVerifiedStep = async () => { + const utils = render(, { + apiValue: { + ...apiValueMock, + loadMicrodepositByGuid: vi + .fn() + .mockResolvedValueOnce({ guid: 'MD-123', status: MicrodepositsStatuses.PREINITIATED }) + .mockResolvedValue({ guid: 'MD-123', status: MicrodepositsStatuses.VERIFIED }), + refreshMicrodepositStatus: vi.fn().mockResolvedValue(undefined), + } as unknown as typeof apiValueMock, + preloadedState: microdepositsEnabledState('MD-123'), + }) + + await screen.findByText('Deposits verified', {}, { timeout: 4000 }) + + return utils + } + + describe('Rendering', () => { + it('renders the deposits verified header and success message', () => { + renderVerified() + + expect(screen.getByText('Deposits verified')).toBeInTheDocument() + expect(screen.getByTestId('verified-paragraph')).toHaveTextContent( + "You're almost done setting things up. Continue to your institution.", + ) + }) + + it('renders the verified graphic and continue button', () => { + renderVerified() + + expect(screen.getByTestId('svg-header')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument() + }) + + it('announces the verified status to screen readers', async () => { + renderVerified() + + expect( + await screen.findByText( + "Deposits verified. You're almost done setting things up. Continue to your institution.", + ), + ).toBeInTheDocument() + }) + }) + + describe('Continue', () => { + it('returns the user to search when continue is clicked', async () => { + const { user } = await renderVerifiedStep() + + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByTestId('search-header')).toBeInTheDocument() + }) + }) + + describe('Accessibility', () => { + it('uses a semantic H2 heading for the title', () => { + renderVerified() + + const heading = screen.getByRole('heading', { name: 'Deposits verified' }) + expect(heading.tagName).toBe('H2') + }) + + it('marks the decorative graphic as hidden from assistive tech', () => { + renderVerified() + + expect(screen.getByTestId('svg-header')).toHaveAttribute('aria-hidden', 'true') + }) + }) +}) diff --git a/src/views/microdeposits/VerifyDeposits-test.tsx b/src/views/microdeposits/VerifyDeposits-test.tsx new file mode 100644 index 0000000000..8035cc8684 --- /dev/null +++ b/src/views/microdeposits/VerifyDeposits-test.tsx @@ -0,0 +1,270 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import RenderConnectStep from 'src/components/RenderConnectStep' +import { VerifyDeposits } from 'src/views/microdeposits/VerifyDeposits' +import { render, screen, waitFor } from 'src/utilities/testingLibrary' +import { MicrodepositsStatuses } from 'src/views/microdeposits/const' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { initialState } from 'src/services/mockedData' +import { STEPS, VERIFY_MODE } from 'src/const/Connect' + +interface Microdeposit { + guid: string + account_name: string + status?: number +} + +type ApiOverrides = Record + +describe('VerifyDeposits', () => { + const microdeposit = (overrides: Partial = {}): Microdeposit => ({ + guid: 'MD-123', + account_name: 'My Checking Account', + ...overrides, + }) + + const renderVerifyDeposits = ( + overrides: Partial = {}, + apiOverrides: ApiOverrides = {}, + ) => + render( {}} />, { + apiValue: { + ...apiValueMock, + verifyMicrodeposit: () => Promise.resolve({}), + ...apiOverrides, + } as unknown as typeof apiValueMock, + }) + + const connectStepProps = { + availableAccountTypes: [], + handleConsentGoBack: () => {}, + handleCredentialsGoBack: () => {}, + navigationRef: React.createRef(), + onManualAccountAdded: () => {}, + onUpsertMember: () => {}, + setConnectLocalState: () => {}, + } + + const microdepositsEnabledState = (currentMicrodepositGuid: string) => ({ + ...initialState, + config: { ...initialState.config, mode: VERIFY_MODE }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.MICRODEPOSITS }], + currentMicrodepositGuid, + }, + profiles: { + ...initialState.profiles, + clientProfile: { + ...initialState.profiles.clientProfile, + account_verification_is_enabled: true, + is_microdeposits_enabled: true, + }, + widgetProfile: { + ...initialState.profiles.widgetProfile, + show_microdeposits_in_connect: true, + }, + }, + }) + + const renderVerifyDepositsStep = async () => { + const utils = render(, { + apiValue: { + ...apiValueMock, + loadMicrodepositByGuid: vi + .fn() + .mockResolvedValueOnce({ guid: 'MD-123', status: MicrodepositsStatuses.PREINITIATED }) + .mockResolvedValue({ + guid: 'MD-123', + account_name: 'My Checking Account', + status: MicrodepositsStatuses.DEPOSITED, + }), + refreshMicrodepositStatus: vi.fn().mockResolvedValue(undefined), + verifyMicrodeposit: vi.fn().mockResolvedValue({}), + } as unknown as typeof apiValueMock, + preloadedState: microdepositsEnabledState('MD-123'), + }) + + await screen.findByText('Enter deposit amounts', {}, { timeout: 4000 }) + + return utils + } + + describe('Rendering', () => { + it('renders the header and the instructions with the account name', () => { + renderVerifyDeposits() + + expect(screen.getByText('Enter deposit amounts')).toBeInTheDocument() + expect(screen.getByTestId('deposit-paragraph')).toHaveTextContent( + 'Please find the two small deposits less than a dollar each in your My Checking Account account, and enter the amounts here.', + ) + }) + + it('reflects a different account name in the instructions', () => { + renderVerifyDeposits({ account_name: 'Savings Account' }) + + expect(screen.getByTestId('deposit-paragraph')).toHaveTextContent('Savings Account') + }) + + it('renders the two required amount fields and the continue button', () => { + renderVerifyDeposits() + + expect(screen.getByLabelText('Amount 1 *')).toBeRequired() + expect(screen.getByLabelText('Amount 2 *')).toBeRequired() + expect(screen.getByTestId('amount-1-input')).toHaveAttribute('placeholder', '0.00') + expect(screen.getByTestId('amount-2-input')).toHaveAttribute('placeholder', '0.00') + expect(screen.getByText('Required')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Continue' })).toBeInTheDocument() + }) + }) + + describe('Denied status', () => { + it('shows the incorrect-amounts alert when the microdeposit was denied', () => { + renderVerifyDeposits({ status: MicrodepositsStatuses.DENIED }) + + const alert = screen.getByTestId('input-error-messagebox') + expect(alert).toHaveAttribute('role', 'alert') + expect(screen.getByTestId('input-error-text')).toHaveTextContent( + 'One or more of the amounts was incorrect. Please try again.', + ) + }) + + it('does not show the alert for a pending microdeposit', () => { + renderVerifyDeposits() + + expect(screen.queryByTestId('input-error-messagebox')).not.toBeInTheDocument() + }) + }) + + describe('Form input', () => { + it('updates and clears the amount fields as the user types', async () => { + const { user } = renderVerifyDeposits() + + const firstInput = screen.getByTestId('amount-1-input') + await user.type(firstInput, '0.05') + await user.type(screen.getByTestId('amount-2-input'), '0.07') + + expect(firstInput).toHaveValue('0.05') + expect(screen.getByTestId('amount-2-input')).toHaveValue('0.07') + + await user.clear(firstInput) + expect(firstInput).toHaveValue('') + }) + }) + + describe('Validation', () => { + it('shows validation errors and focuses the first amount on empty submit', async () => { + const { user } = renderVerifyDeposits() + + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect((await screen.findAllByText('Amount 1 must be a number'))[0]).toBeInTheDocument() + expect(screen.getByTestId('amount-1-input')).toHaveAttribute('aria-invalid', 'true') + expect(screen.getByTestId('amount-2-input')).toHaveAttribute('aria-invalid', 'true') + await waitFor(() => expect(screen.getByTestId('amount-1-input')).toHaveFocus()) + }) + + it('focuses the second amount when only it is invalid', async () => { + const { user } = renderVerifyDeposits() + + await user.type(screen.getByTestId('amount-1-input'), '0.05') + await user.click(screen.getByRole('button', { name: 'Continue' })) + + await waitFor(() => expect(screen.getByTestId('amount-2-input')).toHaveFocus()) + expect(screen.getByTestId('amount-2-input')).toHaveAttribute('aria-invalid', 'true') + }) + + it('rejects a non-numeric amount', async () => { + const { user } = renderVerifyDeposits() + + await user.type(screen.getByTestId('amount-1-input'), 'abc') + await user.type(screen.getByTestId('amount-2-input'), '0.05') + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect((await screen.findAllByText('Amount 1 must be a number'))[0]).toBeInTheDocument() + expect(screen.getByTestId('amount-1-input')).toHaveAttribute('aria-invalid', 'true') + }) + + it('rejects an amount below the minimum', async () => { + const { user } = renderVerifyDeposits() + + await user.type(screen.getByTestId('amount-1-input'), '0.00') + await user.type(screen.getByTestId('amount-2-input'), '0.05') + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect( + (await screen.findAllByText('Amount 1 must be greater than or equal to 0.01'))[0], + ).toBeInTheDocument() + }) + + it('rejects an amount above the maximum', async () => { + const { user } = renderVerifyDeposits() + + await user.type(screen.getByTestId('amount-1-input'), '0.10') + await user.type(screen.getByTestId('amount-2-input'), '0.05') + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect( + (await screen.findAllByText('Amount 1 must be less than or equal to 0.09'))[0], + ).toBeInTheDocument() + }) + }) + + describe('Submission', () => { + it('advances to the verifying screen after submitting valid amounts', async () => { + const { user } = await renderVerifyDepositsStep() + + await user.type(screen.getByTestId('amount-1-input'), '0.05') + await user.type(screen.getByTestId('amount-2-input'), '0.07') + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByText('Verifying ...')).toBeInTheDocument() + expect(screen.getByText('Checking microdeposit amounts.')).toBeInTheDocument() + }) + + it('shows a submission error and stays on the form when the API rejects', async () => { + const { user } = renderVerifyDeposits( + {}, + { verifyMicrodeposit: () => Promise.reject(new Error('API Error')) }, + ) + + await user.type(screen.getByTestId('amount-1-input'), '0.05') + await user.type(screen.getByTestId('amount-2-input'), '0.07') + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByTestId('input-error-text')).toHaveTextContent( + "We're unable to submit your deposit amounts. Please try again.", + ) + expect(screen.getByText('Enter deposit amounts')).toBeInTheDocument() + }) + }) + + describe('Accessibility', () => { + it('uses a semantic H2 heading for the title', () => { + renderVerifyDeposits() + + const heading = screen.getByRole('heading', { name: 'Enter deposit amounts' }) + expect(heading.tagName).toBe('H2') + }) + + it('links each amount field to its error message when invalid', async () => { + const { user } = renderVerifyDeposits() + + await user.type(screen.getByTestId('amount-1-input'), '0.05') + await user.click(screen.getByRole('button', { name: 'Continue' })) + + expect(await screen.findByTestId('amount-2-input')).toHaveAttribute( + 'aria-describedby', + 'secondAmount-error', + ) + }) + + it('disables autocomplete on both amount fields', () => { + renderVerifyDeposits() + + expect(screen.getByTestId('amount-1-input')).toHaveAttribute('autocomplete', 'off') + expect(screen.getByTestId('amount-2-input')).toHaveAttribute('autocomplete', 'off') + }) + }) +}) diff --git a/src/views/microdeposits/Verifying-test.tsx b/src/views/microdeposits/Verifying-test.tsx new file mode 100644 index 0000000000..c22d4150d6 --- /dev/null +++ b/src/views/microdeposits/Verifying-test.tsx @@ -0,0 +1,120 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import RenderConnectStep from 'src/components/RenderConnectStep' +import { Verifying } from 'src/views/microdeposits/Verifying' +import { render, screen } from 'src/utilities/testingLibrary' +import { MicrodepositsStatuses } from 'src/views/microdeposits/const' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { initialState } from 'src/services/mockedData' +import { STEPS, VERIFY_MODE } from 'src/const/Connect' + +type ApiOverrides = Record + +describe('Verifying', () => { + const renderVerifying = (apiOverrides: ApiOverrides = {}) => + render( + {}} + onSuccess={() => {}} + />, + { + apiValue: { + ...apiValueMock, + refreshMicrodepositStatus: () => Promise.resolve({}), + loadMicrodepositByGuid: () => + Promise.resolve({ guid: 'MD-123', status: MicrodepositsStatuses.DEPOSITED }), + ...apiOverrides, + } as unknown as typeof apiValueMock, + }, + ) + + const connectStepProps = { + availableAccountTypes: [], + handleConsentGoBack: () => {}, + handleCredentialsGoBack: () => {}, + navigationRef: React.createRef(), + onManualAccountAdded: () => {}, + onUpsertMember: () => {}, + setConnectLocalState: () => {}, + } + + const microdepositsEnabledState = (currentMicrodepositGuid: string) => ({ + ...initialState, + config: { ...initialState.config, mode: VERIFY_MODE }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.MICRODEPOSITS }], + currentMicrodepositGuid, + }, + profiles: { + ...initialState.profiles, + clientProfile: { + ...initialState.profiles.clientProfile, + account_verification_is_enabled: true, + is_microdeposits_enabled: true, + }, + widgetProfile: { + ...initialState.profiles.widgetProfile, + show_microdeposits_in_connect: true, + }, + }, + }) + + const renderVerifyingStep = async (finalStatus: number) => { + const utils = render(, { + apiValue: { + ...apiValueMock, + loadMicrodepositByGuid: vi + .fn() + .mockResolvedValueOnce({ guid: 'MD-123', status: MicrodepositsStatuses.PREINITIATED }) + .mockResolvedValueOnce({ + guid: 'MD-123', + account_name: 'My Checking Account', + status: MicrodepositsStatuses.DEPOSITED, + }) + .mockResolvedValue({ + guid: 'MD-123', + account_name: 'My Checking Account', + status: finalStatus, + }), + refreshMicrodepositStatus: vi.fn().mockResolvedValue(undefined), + verifyMicrodeposit: vi.fn().mockResolvedValue({}), + } as unknown as typeof apiValueMock, + preloadedState: microdepositsEnabledState('MD-123'), + }) + + await screen.findByText('Enter deposit amounts', {}, { timeout: 4000 }) + + await utils.user.type(screen.getByTestId('amount-1-input'), '0.05') + await utils.user.type(screen.getByTestId('amount-2-input'), '0.07') + await utils.user.click(screen.getByRole('button', { name: 'Continue' })) + + await screen.findByText('Verifying ...', {}, { timeout: 4000 }) + } + + describe('Rendering', () => { + it('renders the verifying header, checking-amounts message, and loading spinner', () => { + renderVerifying() + + const heading = screen.getByRole('heading', { name: 'Verifying ...' }) + expect(heading).toBeInTheDocument() + expect(heading.tagName).toBe('H2') + expect(screen.getByTestId('checking-amounts-paragraph')).toHaveTextContent( + 'Checking microdeposit amounts.', + ) + expect(document.querySelector('[data-ui-test="kyper-spinner"]')).toBeInTheDocument() + }) + }) + + describe('Success Flow', () => { + it('advances to the verified screen when the deposit is verified', async () => { + await renderVerifyingStep(MicrodepositsStatuses.VERIFIED) + + expect( + await screen.findByText('Deposits verified', {}, { timeout: 10000 }), + ).toBeInTheDocument() + }, 15000) + }) +}) diff --git a/src/views/oauth/OAuthStartError-test.tsx b/src/views/oauth/OAuthStartError-test.tsx new file mode 100644 index 0000000000..0d47a0c1fa --- /dev/null +++ b/src/views/oauth/OAuthStartError-test.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { OAuthStartError } from 'src/views/oauth/OAuthStartError' +import { OAuthStep } from 'src/views/oauth/OAuthStep' +import { render, screen } from 'src/utilities/testingLibrary' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { institutionData, member } from 'src/services/mockedData' + +describe('OAuthStartError', () => { + const defaultProps = { + institution: institutionData.institution, + oauthStartError: { response: { status: 500 } }, + onOAuthTryAgain: () => {}, + } + + describe('Rendering', () => { + it('renders the institution, the error title and message, and a Try again button', () => { + render() + + expect(screen.getByText(institutionData.institution.name)).toBeInTheDocument() + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + expect(screen.getByText('Please try again or come back later.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument() + }) + + it('shows the 403 error message when verification is not enabled', () => { + render() + + expect( + screen.getByText('Verification must be enabled to use this feature.'), + ).toBeInTheDocument() + }) + + it('shows the 409 error message for a conflicting request', () => { + render() + + expect( + screen.getByText( + 'Oops! There was a problem. Please check your username and password, and try again.', + ), + ).toBeInTheDocument() + }) + }) + + describe('Try again', () => { + it('restarts the OAuth flow and shows the sign-in view when clicked', async () => { + const OAuthStepComponent = OAuthStep as unknown as React.ComponentType<{ + institution: typeof institutionData.institution + onGoBack: () => void + }> + const addMember = vi + .fn() + .mockRejectedValueOnce({ response: { status: 500 } }) + .mockResolvedValue({ member: member.member }) + + const { user } = render( + {}} />, + { + apiValue: { ...apiValueMock, addMember } as unknown as typeof apiValueMock, + }, + ) + + expect(await screen.findByText('Something went wrong')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Try again' })) + expect( + await screen.findByText(`Log in at ${institutionData.institution.name}`), + ).toBeInTheDocument() + }) + }) +}) diff --git a/src/views/search/views/PopularInstitutionsList-test.tsx b/src/views/search/views/PopularInstitutionsList-test.tsx new file mode 100644 index 0000000000..a6072e01b9 --- /dev/null +++ b/src/views/search/views/PopularInstitutionsList-test.tsx @@ -0,0 +1,120 @@ +import React from 'react' +import { describe, expect, it } from 'vitest' + +import RenderConnectStep from 'src/components/RenderConnectStep' +import { render, screen } from 'src/utilities/testingLibrary' +import { initialState } from 'src/services/mockedData' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { AccountTypes } from 'src/views/manualAccount/constants' +import { STEPS } from 'src/const/Connect' + +type StateOverrides = { + enableManualAccounts?: boolean + mode?: string + hasAtriumApi?: boolean +} + +const buildSearchState = ({ + enableManualAccounts = true, + mode = 'aggregation', + hasAtriumApi = false, +}: StateOverrides = {}) => ({ + ...initialState, + config: { ...initialState.config, mode }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.SEARCH }], + }, + profiles: { + ...initialState.profiles, + client: { ...initialState.profiles.client, has_atrium_api: hasAtriumApi }, + widgetProfile: { + ...initialState.profiles.widgetProfile, + enable_manual_accounts: enableManualAccounts, + }, + }, +}) + +const renderSearchStep = (preloadedState = buildSearchState()) => + render( + {}} + handleCredentialsGoBack={() => {}} + navigationRef={React.createRef()} + onManualAccountAdded={() => {}} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + />, + { apiValue: apiValueMock, preloadedState }, + ) + +describe('PopularInstitutionsList', () => { + describe('Rendering', () => { + it('renders the enabled popular institutions, the search button, and hides client-disabled ones', async () => { + renderSearchStep() + + expect(await screen.findByText('Gringotts')).toBeInTheDocument() + expect(screen.getByText('American Express Credit Card')).toBeInTheDocument() + expect(screen.getByText('Discover Credit Card')).toBeInTheDocument() + expect(screen.getByText('Capital One')).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Search for your institution' }), + ).toBeInTheDocument() + // Chase is disabled_by_client and should be filtered out. + expect(screen.queryByText('Chase')).not.toBeInTheDocument() + }) + }) + + describe('Search button', () => { + it('focuses the search input when the search button is clicked', async () => { + const { user } = renderSearchStep() + + await user.click(await screen.findByRole('button', { name: 'Search for your institution' })) + + expect(screen.getByTestId('search-input')).toHaveFocus() + }) + }) + + describe('Institution selection', () => { + it('advances past the search screen when an institution is selected', async () => { + const { user } = renderSearchStep() + + await user.click(await screen.findByRole('button', { name: 'Add account with Gringotts' })) + + expect(await screen.findByText('Log in at Test Bank')).toBeInTheDocument() + expect(screen.queryByTestId('search-header')).not.toBeInTheDocument() + }) + }) + + describe('Manual accounts', () => { + it('shows the add-account-manually button and navigates to the menu when clicked', async () => { + const { user } = renderSearchStep(buildSearchState({ enableManualAccounts: true })) + + await user.click(await screen.findByRole('button', { name: 'Add account manually' })) + + expect(await screen.findByTestId('manual-account-menu-container')).toBeInTheDocument() + }) + + it('hides the button when manual accounts are disabled', async () => { + renderSearchStep(buildSearchState({ enableManualAccounts: false })) + + await screen.findByRole('button', { name: 'Search for your institution' }) + expect(screen.queryByRole('button', { name: 'Add account manually' })).not.toBeInTheDocument() + }) + + it('hides the button in verification mode', async () => { + renderSearchStep(buildSearchState({ mode: 'verification' })) + + await screen.findByRole('button', { name: 'Search for your institution' }) + expect(screen.queryByRole('button', { name: 'Add account manually' })).not.toBeInTheDocument() + }) + + it('hides the button when the client uses the Atrium API', async () => { + renderSearchStep(buildSearchState({ hasAtriumApi: true })) + + await screen.findByRole('button', { name: 'Search for your institution' }) + expect(screen.queryByRole('button', { name: 'Add account manually' })).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/views/search/views/SearchFailed-test.tsx b/src/views/search/views/SearchFailed-test.tsx new file mode 100644 index 0000000000..3c86fb3f67 --- /dev/null +++ b/src/views/search/views/SearchFailed-test.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import { describe, expect, it } from 'vitest' + +import { SearchFailed } from 'src/views/search/views/SearchFailed' +import { render, screen } from 'src/utilities/testingLibrary' + +describe('SearchFailed', () => { + it('renders the search error title and message', () => { + render() + + expect(screen.getByText(/Search isn.t working/)).toBeVisible() + expect(screen.getByText('Something went wrong. Please try again.')).toBeVisible() + }) +}) diff --git a/src/views/search/views/SearchNoResult-test.tsx b/src/views/search/views/SearchNoResult-test.tsx new file mode 100644 index 0000000000..5fad223ded --- /dev/null +++ b/src/views/search/views/SearchNoResult-test.tsx @@ -0,0 +1,145 @@ +import React from 'react' +import { describe, expect, it } from 'vitest' + +import RenderConnectStep from 'src/components/RenderConnectStep' +import { fireEvent, render, screen } from 'src/utilities/testingLibrary' +import { initialState } from 'src/services/mockedData' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { AccountTypes } from 'src/views/manualAccount/constants' +import { STEPS } from 'src/const/Connect' + +type StateOverrides = { + mode?: string + enableManualAccounts?: boolean + hasAtriumApi?: boolean + accountVerificationEnabled?: boolean + isMicrodepositsEnabled?: boolean + showMicrodeposits?: boolean +} + +const buildSearchState = ({ + mode = 'aggregation', + enableManualAccounts = true, + hasAtriumApi = false, + accountVerificationEnabled = false, + isMicrodepositsEnabled = false, + showMicrodeposits = false, +}: StateOverrides = {}) => ({ + ...initialState, + config: { ...initialState.config, mode }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.SEARCH }], + }, + profiles: { + ...initialState.profiles, + client: { ...initialState.profiles.client, has_atrium_api: hasAtriumApi }, + clientProfile: { + ...initialState.profiles.clientProfile, + account_verification_is_enabled: accountVerificationEnabled, + is_microdeposits_enabled: isMicrodepositsEnabled, + }, + widgetProfile: { + ...initialState.profiles.widgetProfile, + enable_manual_accounts: enableManualAccounts, + show_microdeposits_in_connect: showMicrodeposits, + }, + }, +}) + +const microdepositsEnabledState = () => + buildSearchState({ + mode: 'verification', + accountVerificationEnabled: true, + isMicrodepositsEnabled: true, + showMicrodeposits: true, + }) + +const renderNoResultStep = (preloadedState = buildSearchState()) => + render( + {}} + handleCredentialsGoBack={() => {}} + navigationRef={React.createRef()} + onManualAccountAdded={() => {}} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + />, + { + apiValue: { ...apiValueMock, loadInstitutions: () => Promise.resolve([]) }, + preloadedState, + }, + ) + +const startSearch = async (term = 'test bank') => { + fireEvent.change(await screen.findByPlaceholderText('Search'), { target: { value: term } }) +} + +describe('SearchNoResult', () => { + describe('Rendering', () => { + it('renders the no-results message with the search term and a suggestion', async () => { + renderNoResultStep() + + await startSearch('test bank') + + const message = await screen.findByTestId('0-search-results') + expect(message).toHaveTextContent(/No results found/) + expect(message).toHaveTextContent(/test bank/) + expect( + screen.getByText('Check spelling and try again, or try searching for another institution.'), + ).toBeVisible() + }) + }) + + describe('Manual accounts', () => { + it('shows the add-account-manually button and navigates to the menu when clicked', async () => { + const { user } = renderNoResultStep() + + await startSearch() + + await user.click(await screen.findByRole('button', { name: 'Add account manually' })) + + expect(await screen.findByTestId('manual-account-menu-container')).toBeInTheDocument() + }) + + it.each([ + ['manual accounts are disabled', buildSearchState({ enableManualAccounts: false })], + ['in verification mode', buildSearchState({ mode: 'verification' })], + ['the client uses the Atrium API', buildSearchState({ hasAtriumApi: true })], + ])('hides the button when %s', async (_case, state) => { + renderNoResultStep(state) + + await startSearch() + await screen.findByTestId('0-search-results') + + expect(screen.queryByRole('button', { name: 'Add account manually' })).not.toBeInTheDocument() + }) + }) + + describe('Microdeposits', () => { + it('shows the connect-with-account-numbers button and navigates to microdeposits', async () => { + const { user } = renderNoResultStep(microdepositsEnabledState()) + + await startSearch() + + await user.click(await screen.findByRole('button', { name: 'Connect with account numbers' })) + + expect(await screen.findByText('Enter routing number')).toBeInTheDocument() + }) + + it.each([ + ['not in verification mode', buildSearchState()], + ['verification is not enabled', buildSearchState({ mode: 'verification' })], + ])('hides the button when %s', async (_case, state) => { + renderNoResultStep(state) + + await startSearch() + await screen.findByTestId('0-search-results') + + expect( + screen.queryByRole('button', { name: 'Connect with account numbers' }), + ).not.toBeInTheDocument() + }) + }) +}) diff --git a/src/views/search/views/SearchedInstitutionsList-test.tsx b/src/views/search/views/SearchedInstitutionsList-test.tsx new file mode 100644 index 0000000000..d7278360b3 --- /dev/null +++ b/src/views/search/views/SearchedInstitutionsList-test.tsx @@ -0,0 +1,180 @@ +import React from 'react' +import { describe, expect, it } from 'vitest' + +import RenderConnectStep from 'src/components/RenderConnectStep' +import { fireEvent, render, screen } from 'src/utilities/testingLibrary' +import { initialState, SEARCHED_INSTITUTIONS } from 'src/services/mockedData' +import { apiValue as apiValueMock } from 'src/const/apiProviderMock' +import { AccountTypes } from 'src/views/manualAccount/constants' +import { STEPS } from 'src/const/Connect' + +type StateOverrides = { + mode?: string + enableManualAccounts?: boolean + hasAtriumApi?: boolean + accountVerificationEnabled?: boolean + isMicrodepositsEnabled?: boolean + showMicrodeposits?: boolean +} + +const buildSearchState = ({ + mode = 'aggregation', + enableManualAccounts = true, + hasAtriumApi = false, + accountVerificationEnabled = false, + isMicrodepositsEnabled = false, + showMicrodeposits = false, +}: StateOverrides = {}) => ({ + ...initialState, + config: { ...initialState.config, mode }, + connect: { + ...initialState.connect, + location: [{ step: STEPS.SEARCH }], + }, + profiles: { + ...initialState.profiles, + client: { ...initialState.profiles.client, has_atrium_api: hasAtriumApi }, + clientProfile: { + ...initialState.profiles.clientProfile, + account_verification_is_enabled: accountVerificationEnabled, + is_microdeposits_enabled: isMicrodepositsEnabled, + }, + widgetProfile: { + ...initialState.profiles.widgetProfile, + enable_manual_accounts: enableManualAccounts, + show_microdeposits_in_connect: showMicrodeposits, + }, + }, +}) + +const microdepositsEnabledState = () => + buildSearchState({ + mode: 'verification', + accountVerificationEnabled: true, + isMicrodepositsEnabled: true, + showMicrodeposits: true, + }) + +const renderSearchStep = ( + preloadedState = buildSearchState(), + apiOverrides: Partial = {}, +) => + render( + {}} + handleCredentialsGoBack={() => {}} + navigationRef={React.createRef()} + onManualAccountAdded={() => {}} + onUpsertMember={() => {}} + setConnectLocalState={() => {}} + />, + { apiValue: { ...apiValueMock, ...apiOverrides }, preloadedState }, + ) + +const startSearch = async () => { + fireEvent.change(await screen.findByPlaceholderText('Search'), { target: { value: 'test' } }) +} + +describe('SearchedInstitutionsList', () => { + describe('Rendering', () => { + it('renders the search results, the result count, and a load-more button', async () => { + renderSearchStep() + + await startSearch() + + expect(await screen.findByText('MX Bank')).toBeInTheDocument() + expect(screen.getByText('Grinnell State Bank')).toBeInTheDocument() + expect(screen.getAllByText('5 search results').length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: 'Load more institutions' })).toBeInTheDocument() + }) + + it('shows a singular result count when a single institution matches', async () => { + renderSearchStep(buildSearchState(), { + loadInstitutions: () => Promise.resolve([SEARCHED_INSTITUTIONS[1]]), + }) + + await startSearch() + + expect(await screen.findByText('MX Bank')).toBeInTheDocument() + expect(screen.getAllByText('1 search result').length).toBeGreaterThan(0) + }) + }) + + describe('Institution selection', () => { + it('advances past the search screen when a searched institution is selected', async () => { + const { user } = renderSearchStep() + + await startSearch() + + await user.click(await screen.findByRole('button', { name: 'Add account with MX Bank' })) + + expect(await screen.findByText('Log in at Test Bank')).toBeInTheDocument() + expect(screen.queryByTestId('search-header')).not.toBeInTheDocument() + }) + }) + + describe('Load more institutions', () => { + it('appends the next page of results when the load-more button is clicked', async () => { + const { user } = renderSearchStep() + + await startSearch() + await screen.findByText('Grinnell State Bank') + + await user.click(screen.getByRole('button', { name: 'Load more institutions' })) + + expect((await screen.findAllByText('10 search results')).length).toBeGreaterThan(0) + }) + }) + + describe('Manual accounts', () => { + it('shows the add-account-manually button and navigates to the menu when clicked', async () => { + const { user } = renderSearchStep() + + await startSearch() + + await user.click(await screen.findByRole('button', { name: 'Add account manually' })) + + expect(await screen.findByTestId('manual-account-menu-container')).toBeInTheDocument() + }) + + it.each([ + ['manual accounts are disabled', buildSearchState({ enableManualAccounts: false })], + ['in verification mode', buildSearchState({ mode: 'verification' })], + ['the client uses the Atrium API', buildSearchState({ hasAtriumApi: true })], + ])('hides the button when %s', async (_case, state) => { + renderSearchStep(state) + + await startSearch() + await screen.findByText('Grinnell State Bank') + + expect(screen.queryByRole('button', { name: 'Add account manually' })).not.toBeInTheDocument() + }) + }) + + describe('Microdeposits', () => { + it('shows the connect-with-account-numbers button and navigates to microdeposits', async () => { + const { user } = renderSearchStep(microdepositsEnabledState()) + + await startSearch() + + await user.click(await screen.findByRole('button', { name: 'Connect with account numbers' })) + + expect(await screen.findByText('Enter routing number')).toBeInTheDocument() + }) + + it.each([ + ['not in verification mode', buildSearchState()], + ['verification is not enabled', buildSearchState({ mode: 'verification' })], + ])('hides the button when %s', async (_case, state) => { + renderSearchStep(state) + + await startSearch() + await screen.findByText('Grinnell State Bank') + + expect( + screen.queryByRole('button', { name: 'Connect with account numbers' }), + ).not.toBeInTheDocument() + }) + }) +}) diff --git a/styles.css b/styles.css deleted file mode 100644 index fcac5837e9..0000000000 --- a/styles.css +++ /dev/null @@ -1,8 +0,0 @@ -@font-face { - font-family: 'MaterialSymbolsRounded'; - font-display: block; - font-style: normal; - font-weight: 100 700; - src: url('https://s3.amazonaws.com/MD_Assets/fonts/icon/MaterialSymbolsRounded.woff2') - format('woff2'); -} diff --git a/vite.config.ts b/vite.config.ts index 6eb0e5c278..5bb716b9dd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -92,5 +92,23 @@ export default defineConfig({ inline: ['@mxenabled/mx-icons'], }, }, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'src/testSetup.ts', + 'src/index.ts', + 'src/main.tsx', + '**/*.d.ts', + '**/*-{test,spec}.{js,ts,jsx,tsx}', + '**/__tests__/**', + '**/dist/**', + '.eslintrc.cjs', + 'vite.config.ts', + 'scripts/**', + '**/__mocks__/**', + ], + }, }, })