diff --git a/package.json b/package.json index c25fbb07f..ac82e8ea3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@ianvs/prettier-plugin-sort-imports": "^4.3.1", "@playwright/test": "^1.48.2", "@stylistic/eslint-plugin-ts": "^3.1.0", + "@testing-library/react": "^16.3.0", "@types/node": "^22.8.6", "@types/react": "*", "@types/react-dom": "*", @@ -45,6 +46,7 @@ "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-react": "^7.37.4", "globals": "^15.14.0", + "jsdom": "^26.1.0", "next": "15.2.3", "prettier": "^3.3.3", "typedoc": "^0.27.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 434d5b797..e51069550 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: '@stylistic/eslint-plugin-ts': specifier: ^3.1.0 version: 3.1.0(eslint@9.20.0)(typescript@5.7.3) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@types/node': specifier: ^22.8.6 version: 22.13.1 @@ -53,7 +56,7 @@ importers: version: 19.0.3(@types/react@19.0.8) '@vitest/coverage-v8': specifier: 2.1.4 - version: 2.1.4(vitest@2.1.9(@types/node@22.13.1)) + version: 2.1.4(vitest@2.1.9(@types/node@22.13.1)(jsdom@26.1.0)) eslint: specifier: ^9.20.0 version: 9.20.0 @@ -69,6 +72,9 @@ importers: globals: specifier: ^15.14.0 version: 15.14.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 next: specifier: 15.2.3 version: 15.2.3(@playwright/test@1.50.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -89,7 +95,7 @@ importers: version: 5.4.14(@types/node@22.13.1) vitest: specifier: ^2.1.4 - version: 2.1.9(@types/node@22.13.1) + version: 2.1.9(@types/node@22.13.1)(jsdom@26.1.0) packages: @@ -97,6 +103,9 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.1.2': + resolution: {integrity: sha512-nwgc7jPn3LpZ4JWsoHtuwBsad1qSSLDDX634DdG0PBJofIuIEtSWk4KkRmuXyu178tjuHAbwiMNNzwqIyLYxZw==} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -118,6 +127,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.27.0': + resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.9': resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} engines: {node: '>=6.9.0'} @@ -133,6 +146,34 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@csstools/color-helpers@5.0.2': + resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.2': + resolution: {integrity: sha512-TklMyb3uBB28b5uQdxjReG4L80NxAqgrECqLZFQbyLekwwlcDDS8r3f07DKqeo8C4926Br0gf/ZDe17Zv4wIuw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-color-parser@3.0.8': + resolution: {integrity: sha512-pdwotQjCCnRPuNi06jFuP68cykU1f3ZWExLe/8MQ1LOs8Xq+fTkYgd+2V8mWUWMrOn9iS2HftPVaMZDaXzGbhQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-parser-algorithms@3.0.4': + resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-tokenizer@3.0.3': + resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + engines: {node: '>=18'} + '@edge-runtime/cookies@5.0.2': resolution: {integrity: sha512-Sd8LcWpZk/SWEeKGE8LT6gMm5MGfX/wm+GPnh1eBEtCpya3vYqn37wYknwAHw92ONoyyREl1hJwxV/Qx2DWNOg==} engines: {node: '>=16'} @@ -674,6 +715,28 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -792,6 +855,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -807,6 +874,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -814,6 +885,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -930,9 +1004,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + cssstyle@4.3.0: + resolution: {integrity: sha512-6r0NiY0xizYqfBvWp1G7WXJ06/bZyrk7Dc6PHql82C/pKGUTKu4yAX4Y8JPamb1ob9nBKuxWzCGTRuGwU3yxJQ==} + engines: {node: '>=18'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -954,6 +1036,9 @@ packages: supports-color: optional: true + decimal.js@10.5.0: + resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -981,6 +1066,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1256,9 +1344,25 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1342,6 +1446,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1417,6 +1524,15 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1465,6 +1581,10 @@ packages: lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -1537,6 +1657,9 @@ packages: sass: optional: true + nwsapi@2.2.20: + resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + oauth4webapi@3.1.4: resolution: {integrity: sha512-eVfN3nZNbok2s/ROifO0UAc5G8nRoLSbrcKJ09OqmucgnhXEfdIQOR4gq1eJH1rN3gV7rNw62bDEgftsgFtBEg==} @@ -1591,6 +1714,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@7.2.1: + resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1655,6 +1781,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1677,6 +1807,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} @@ -1685,6 +1818,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -1706,6 +1842,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1721,6 +1860,13 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -1862,6 +2008,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.9.2: resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1888,10 +2037,25 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.0: + resolution: {integrity: sha512-IUWnUK7ADYR5Sl1fZlO1INDUhVhatWl7BtJWsIhwJ0UAK7ilzzIa8uIqOO/aYVWHZPJkKbEL+362wrzoeRF7bw==} + engines: {node: '>=18'} + ts-api-utils@2.0.1: resolution: {integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==} engines: {node: '>=18.12'} @@ -2019,6 +2183,26 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2057,6 +2241,25 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + ws@8.18.1: + resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.7.0: resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} engines: {node: '>= 14'} @@ -2073,6 +2276,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.8 '@jridgewell/trace-mapping': 0.3.25 + '@asamuzakjp/css-color@3.1.2': + dependencies: + '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-color-parser': 3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + lru-cache: 10.4.3 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -2095,6 +2306,10 @@ snapshots: dependencies: '@babel/types': 7.26.7 + '@babel/runtime@7.27.0': + dependencies: + regenerator-runtime: 0.14.1 + '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 @@ -2120,6 +2335,26 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@csstools/color-helpers@5.0.2': {} + + '@csstools/css-calc@2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-color-parser@3.0.8(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/color-helpers': 5.0.2 + '@csstools/css-calc': 2.1.2(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-tokenizer@3.0.3': {} + '@edge-runtime/cookies@5.0.2': {} '@emnapi/runtime@1.3.1': @@ -2509,6 +2744,29 @@ snapshots: dependencies: tslib: 2.8.1 + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.27.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@babel/runtime': 7.27.0 + '@testing-library/dom': 10.4.0 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.8 + '@types/react-dom': 19.0.3(@types/react@19.0.8) + + '@types/aria-query@5.0.4': {} + '@types/estree@1.0.6': {} '@types/hast@3.0.4': @@ -2608,7 +2866,7 @@ snapshots: '@typescript-eslint/types': 8.24.0 eslint-visitor-keys: 4.2.0 - '@vitest/coverage-v8@2.1.4(vitest@2.1.9(@types/node@22.13.1))': + '@vitest/coverage-v8@2.1.4(vitest@2.1.9(@types/node@22.13.1)(jsdom@26.1.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -2622,7 +2880,7 @@ snapshots: std-env: 3.8.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@22.13.1) + vitest: 2.1.9(@types/node@22.13.1)(jsdom@26.1.0) transitivePeerDependencies: - supports-color @@ -2672,6 +2930,8 @@ snapshots: acorn@8.14.0: {} + agent-base@7.1.3: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -2687,10 +2947,16 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.3 @@ -2839,8 +3105,18 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cssstyle@4.3.0: + dependencies: + '@asamuzakjp/css-color': 3.1.2 + rrweb-cssom: 0.8.0 + csstype@3.1.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.3 @@ -2863,6 +3139,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.5.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -2888,6 +3166,8 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.1 @@ -3283,8 +3563,30 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -3371,6 +3673,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.3 @@ -3458,6 +3762,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.3.0 + data-urls: 5.0.0 + decimal.js: 10.5.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.20 + parse5: 7.2.1 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -3502,6 +3833,8 @@ snapshots: lunr@2.3.9: {} + lz-string@1.5.0: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -3578,6 +3911,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + nwsapi@2.2.20: {} + oauth4webapi@3.1.4: {} object-assign@4.1.1: {} @@ -3644,6 +3979,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@7.2.1: + dependencies: + entities: 4.5.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3693,6 +4032,12 @@ snapshots: prettier@3.4.2: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3712,6 +4057,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.0.0: {} reflect.getprototypeof@1.0.10: @@ -3725,6 +4072,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regenerator-runtime@0.14.1: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -3769,6 +4118,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.34.5 fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3792,6 +4143,12 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.25.0: {} semver@6.3.1: {} @@ -3981,6 +4338,8 @@ snapshots: react: 19.0.0 use-sync-external-store: 1.4.0(react@19.0.0) + symbol-tree@3.2.4: {} + synckit@0.9.2: dependencies: '@pkgr/core': 0.1.1 @@ -4002,10 +4361,24 @@ snapshots: tinyspy@3.0.2: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.0.1(typescript@5.7.3): dependencies: typescript: 5.7.3 @@ -4116,7 +4489,7 @@ snapshots: '@types/node': 22.13.1 fsevents: 2.3.3 - vitest@2.1.9(@types/node@22.13.1): + vitest@2.1.9(@types/node@22.13.1)(jsdom@26.1.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(vite@5.4.14(@types/node@22.13.1)) @@ -4140,6 +4513,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.13.1 + jsdom: 26.1.0 transitivePeerDependencies: - less - lightningcss @@ -4151,6 +4525,23 @@ snapshots: - supports-color - terser + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.0 + webidl-conversions: 7.0.0 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4214,6 +4605,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + ws@8.18.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.7.0: {} yocto-queue@0.1.0: {} diff --git a/src/client/hooks/use-user.integration.test.tsx b/src/client/hooks/use-user.integration.test.tsx new file mode 100644 index 000000000..9d35594bc --- /dev/null +++ b/src/client/hooks/use-user.integration.test.tsx @@ -0,0 +1,149 @@ +/** + * @vitest-environment jsdom + */ + +import React from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import * as swrModule from "swr"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type MockInstance +} from "vitest"; + +import type { User } from "../../types/index.js"; +import { useUser } from "./use-user.js"; + +// New test suite for integration testing with fetch and SWR cache +describe("useUser Integration with SWR Cache", () => { + const initialUser: User = { + sub: "initial_user_123", + name: "Initial User", + email: "initial@example.com" + }; + const updatedUser: User = { + sub: "updated_user_456", + name: "Updated User", + email: "updated@example.com" + }; + + // Explicitly type fetchSpy using MockInstance and the global fetch signature + let fetchSpy: MockInstance< + ( + input: RequestInfo | URL, + init?: RequestInit | undefined + ) => Promise + >; + + beforeEach(() => { + // Mock the global fetch + fetchSpy = vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + vi.restoreAllMocks(); // Restore original fetch implementation + }); + + it("should fetch initial user data and update after invalidate", async () => { + // Mock fetch to return initial data first + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(initialUser), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map() }}> + {children} + + ); + + const { result } = renderHook(() => useUser(), { wrapper }); + + // Wait for the initial data to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + // Assert initial state + expect(result.current.user).toEqual(initialUser); + expect(result.current.error).toBe(null); + + // Mock fetch to return updated data for the next call + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(updatedUser), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + // Call invalidate to trigger re-fetch + await act(async () => { + result.current.invalidate(); + }); + + // Wait for the hook to reflect the updated data + await waitFor(() => expect(result.current.user).toEqual(updatedUser)); + + // Assert updated state + expect(result.current.user).toEqual(updatedUser); + expect(result.current.error).toBe(null); + expect(result.current.isLoading).toBe(false); + + // Verify fetch was called twice (initial load + invalidate) + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenCalledWith("/auth/profile"); + }); + + it("should handle fetch error during invalidation", async () => { + // Mock fetch to return initial data first + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(initialUser), { + status: 200, + headers: { "Content-Type": "application/json" } + }) + ); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + new Map(), + shouldRetryOnError: false, + dedupingInterval: 0 + }} + > + {children} + + ); + + const { result } = renderHook(() => useUser(), { wrapper }); + + // Wait for the initial data to load + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.user).toEqual(initialUser); + + // Mock fetch to return an error for the next call + const fetchError = new Error("Network Error"); + fetchSpy.mockRejectedValueOnce(fetchError); + + // Call invalidate to trigger re-fetch + await act(async () => { + result.current.invalidate(); + }); + + // Wait for the hook to reflect the error state, user should still be the initial one before error + await waitFor(() => expect(result.current.error).not.toBeNull()); + + // Assert error state - SWR catches the rejection from fetch itself. + // Check for the message of the error we explicitly rejected with. + expect(result.current.user).toBeNull(); // Expect null now, not stale data + expect(result.current.error?.message).toBe(fetchError.message); // Correct assertion + expect(result.current.isLoading).toBe(false); + + // Verify fetch was called twice + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/client/hooks/use-user.ts b/src/client/hooks/use-user.ts index 79a6cfd11..249bf76be 100644 --- a/src/client/hooks/use-user.ts +++ b/src/client/hooks/use-user.ts @@ -5,38 +5,39 @@ import useSWR from "swr"; import type { User } from "../../types"; export function useUser() { - const { data, error, isLoading } = useSWR( + const { data, error, isLoading, mutate } = useSWR( process.env.NEXT_PUBLIC_PROFILE_ROUTE || "/auth/profile", (...args) => fetch(...args).then((res) => { if (!res.ok) { throw new Error("Unauthorized"); } - return res.json(); }) ); - // if we have the user loaded via the provider, return it - if (data) { + if (error) { return { - user: data, + user: null, isLoading: false, - error: null + error, + invalidate: () => mutate() }; } - if (error) { + if (data) { return { - user: null, + user: data, isLoading: false, - error + error: null, + invalidate: () => mutate() }; } return { user: data, isLoading, - error + error, + invalidate: () => mutate() }; } diff --git a/src/client/hooks/use-user.unit.test.tsx b/src/client/hooks/use-user.unit.test.tsx new file mode 100644 index 000000000..bb95bed67 --- /dev/null +++ b/src/client/hooks/use-user.unit.test.tsx @@ -0,0 +1,116 @@ +import * as swrModule from "swr"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "../../types/index.js"; +import { useUser } from "./use-user.js"; + +// Define mockMutate outside the mock factory so it can be referenced in tests +const mockMutate = vi.fn(); + +// Mock the SWR module, preserving original exports like SWRConfig +vi.mock("swr", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + default: vi.fn(() => ({ + // Mock the default export (useSWR hook) + data: undefined, + error: undefined, + isLoading: true, + isValidating: false, + mutate: mockMutate + })) + }; +}); + +describe("useUser", () => { + const mockUser: User = { + sub: "user_123", + name: "Test User", + email: "test@example.com" + }; + + beforeEach(() => { + // Clear mocks before each test + vi.clearAllMocks(); + mockMutate.mockClear(); + }); + + afterEach(() => { + // restoreAllMocks handles spies and mocks + vi.restoreAllMocks(); + }); + + it("should return isLoading when no data or error", () => { + // Reset the global mock implementation for this specific test + vi.mocked(swrModule.default).mockImplementation(() => ({ + data: undefined, + error: undefined, + isLoading: true, + isValidating: false, + mutate: mockMutate + })); + const result = useUser(); + + expect(result.isLoading).toBe(true); + expect(result.user).toBe(undefined); + expect(result.error).toBe(undefined); + expect(typeof result.invalidate).toBe("function"); + }); + + it("should return user data when data is available", () => { + // Mock SWR default export (useSWR hook) to return user data for this test + vi.mocked(swrModule.default).mockImplementationOnce(() => ({ + data: mockUser, + error: undefined, + isLoading: false, + isValidating: false, + mutate: mockMutate + })); + + const result = useUser(); + + expect(result.isLoading).toBe(false); + expect(result.user).toBe(mockUser); + expect(result.error).toBe(null); + expect(typeof result.invalidate).toBe("function"); + }); + + it("should return error when fetch fails", () => { + const mockError = new Error("Unauthorized"); + // Mock SWR default export (useSWR hook) to return error for this test + vi.mocked(swrModule.default).mockImplementationOnce(() => ({ + data: undefined, + error: mockError, + isLoading: false, + isValidating: false, + mutate: mockMutate + })); + + const result = useUser(); + + expect(result.isLoading).toBe(false); + expect(result.user).toBe(null); + expect(result.error).toBe(mockError); + expect(typeof result.invalidate).toBe("function"); + }); + + it("should call mutate when invalidate is called", () => { + // Mock SWR default export (useSWR hook) with mockMutate for invalidate testing + vi.mocked(swrModule.default).mockImplementationOnce(() => ({ + data: mockUser, + error: undefined, + isLoading: false, + isValidating: false, + mutate: mockMutate + })); + + const result = useUser(); + + // Call invalidate function + result.invalidate(); + + // Verify mutate was called + expect(mockMutate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/server/auth-client.test.ts b/src/server/auth-client.test.ts index daf496347..459088b4a 100644 --- a/src/server/auth-client.test.ts +++ b/src/server/auth-client.test.ts @@ -394,7 +394,7 @@ ca/T0LLtgmbMmxSv/MmzIg== // When a route doesn't match, the handler returns a NextResponse.next() with status 200 expect(response.status).toBe(200); }); - + it("should use the default value (true) for enableAccessTokenEndpoint when not explicitly provided", async () => { const secret = await generateSecret(32); const transactionStore = new TransactionStore({ @@ -4374,34 +4374,42 @@ ca/T0LLtgmbMmxSv/MmzIg== const authClient = await createAuthClient({ signInReturnToPath: defaultReturnTo }); - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { expect(state.returnTo).toBe(defaultReturnTo); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin(); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should sanitize and use the provided returnTo parameter", async () => { const authClient = await createAuthClient(); const returnTo = "/custom-return-path"; - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { // The full URL is saved, not just the path expect(state.returnTo).toBe("https://example.com/custom-return-path"); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin({ returnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should reject unsafe returnTo URLs", async () => { @@ -4409,18 +4417,22 @@ ca/T0LLtgmbMmxSv/MmzIg== signInReturnToPath: "/safe-path" }); const unsafeReturnTo = "https://malicious-site.com"; - + // Mock the transactionStore.save method to verify the saved state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, state) => { + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn(async (cookies, state) => { // Should use the default safe path instead of the malicious one expect(state.returnTo).toBe("/safe-path"); - return originalSave.call(authClient['transactionStore'], cookies, state); + return originalSave.call( + authClient["transactionStore"], + cookies, + state + ); }); await authClient.startInteractiveLogin({ returnTo: unsafeReturnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should pass authorization parameters to the authorization URL", async () => { @@ -4429,10 +4441,10 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: "https://api.example.com", scope: "openid profile email custom_scope" }; - + // Spy on the authorizationUrl method to verify the passed params - const originalAuthorizationUrl = authClient['authorizationUrl']; - authClient['authorizationUrl'] = vi.fn(async (params) => { + const originalAuthorizationUrl = authClient["authorizationUrl"]; + authClient["authorizationUrl"] = vi.fn(async (params) => { // Verify the audience is set correctly expect(params.get("audience")).toBe(authorizationParameters.audience); // Verify the scope is set correctly @@ -4441,8 +4453,8 @@ ca/T0LLtgmbMmxSv/MmzIg== }); await authClient.startInteractiveLogin({ authorizationParameters }); - - expect(authClient['authorizationUrl']).toHaveBeenCalled(); + + expect(authClient["authorizationUrl"]).toHaveBeenCalled(); }); it("should handle pushed authorization requests (PAR) correctly", async () => { @@ -4452,11 +4464,11 @@ ca/T0LLtgmbMmxSv/MmzIg== parRequestCalled = true; } }); - + const secret = await generateSecret(32); const transactionStore = new TransactionStore({ secret }); const sessionStore = new StatelessSessionStore({ secret }); - + const authClient = new AuthClient({ transactionStore, sessionStore, @@ -4471,33 +4483,41 @@ ca/T0LLtgmbMmxSv/MmzIg== }, fetch: mockFetch }); - + await authClient.startInteractiveLogin(); - + // Verify that PAR was used expect(parRequestCalled).toBe(true); }); - + it("should save the transaction state with correct values", async () => { const authClient = await createAuthClient(); const returnTo = "/custom-path"; - + // Instead of mocking the oauth functions, we'll just check the structure of the transaction state - const originalSave = authClient['transactionStore'].save; - authClient['transactionStore'].save = vi.fn(async (cookies, transactionState) => { - expect(transactionState).toEqual(expect.objectContaining({ - nonce: expect.any(String), - codeVerifier: expect.any(String), - responseType: "code", - state: expect.any(String), - returnTo: "https://example.com/custom-path" - })); - return originalSave.call(authClient['transactionStore'], cookies, transactionState); - }); + const originalSave = authClient["transactionStore"].save; + authClient["transactionStore"].save = vi.fn( + async (cookies, transactionState) => { + expect(transactionState).toEqual( + expect.objectContaining({ + nonce: expect.any(String), + codeVerifier: expect.any(String), + responseType: "code", + state: expect.any(String), + returnTo: "https://example.com/custom-path" + }) + ); + return originalSave.call( + authClient["transactionStore"], + cookies, + transactionState + ); + } + ); await authClient.startInteractiveLogin({ returnTo }); - - expect(authClient['transactionStore'].save).toHaveBeenCalled(); + + expect(authClient["transactionStore"].save).toHaveBeenCalled(); }); it("should merge configuration authorizationParameters with method arguments", async () => { @@ -4509,13 +4529,13 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: configAudience } }); - + const methodScope = "openid profile email custom_scope"; const methodAudience = "https://custom-api.example.com"; - + // Spy on the authorizationUrl method to verify the passed params - const originalAuthorizationUrl = authClient['authorizationUrl']; - authClient['authorizationUrl'] = vi.fn(async (params) => { + const originalAuthorizationUrl = authClient["authorizationUrl"]; + authClient["authorizationUrl"] = vi.fn(async (params) => { // Method's authorization parameters should override config expect(params.get("audience")).toBe(methodAudience); expect(params.get("scope")).toBe(methodScope); @@ -4528,14 +4548,14 @@ ca/T0LLtgmbMmxSv/MmzIg== audience: methodAudience } }); - - expect(authClient['authorizationUrl']).toHaveBeenCalled(); + + expect(authClient["authorizationUrl"]).toHaveBeenCalled(); }); // Add tests for handleLogin method it("should create correct options in handleLogin with returnTo parameter", async () => { const authClient = await createAuthClient(); - + // Mock startInteractiveLogin to check what options are passed to it const originalStartInteractiveLogin = authClient.startInteractiveLogin; authClient.startInteractiveLogin = vi.fn(async (options) => { @@ -4546,11 +4566,13 @@ ca/T0LLtgmbMmxSv/MmzIg== return originalStartInteractiveLogin.call(authClient, options); }); - const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return"); + const reqUrl = new URL( + "https://example.com/auth/login?foo=bar&returnTo=custom-return" + ); const req = new NextRequest(reqUrl, { method: "GET" }); - + await authClient.handleLogin(req); - + expect(authClient.startInteractiveLogin).toHaveBeenCalled(); }); @@ -4558,7 +4580,7 @@ ca/T0LLtgmbMmxSv/MmzIg== const authClient = await createAuthClient({ pushedAuthorizationRequests: true }); - + // Mock startInteractiveLogin to check what options are passed to it const originalStartInteractiveLogin = authClient.startInteractiveLogin; authClient.startInteractiveLogin = vi.fn(async (options) => { @@ -4569,11 +4591,13 @@ ca/T0LLtgmbMmxSv/MmzIg== return originalStartInteractiveLogin.call(authClient, options); }); - const reqUrl = new URL("https://example.com/auth/login?foo=bar&returnTo=custom-return"); + const reqUrl = new URL( + "https://example.com/auth/login?foo=bar&returnTo=custom-return" + ); const req = new NextRequest(reqUrl, { method: "GET" }); - + await authClient.handleLogin(req); - + expect(authClient.startInteractiveLogin).toHaveBeenCalled(); }); }); diff --git a/src/server/auth-client.ts b/src/server/auth-client.ts index 78a4344e6..44b676305 100644 --- a/src/server/auth-client.ts +++ b/src/server/auth-client.ts @@ -6,21 +6,21 @@ import packageJson from "../../package.json"; import { AccessTokenError, AccessTokenErrorCode, + AccessTokenForConnectionError, + AccessTokenForConnectionErrorCode, AuthorizationCodeGrantError, AuthorizationError, BackchannelLogoutError, DiscoveryError, - AccessTokenForConnectionError, - AccessTokenForConnectionErrorCode, InvalidStateError, MissingStateError, OAuth2Error, SdkError } from "../errors"; import { + AccessTokenForConnectionOptions, AuthorizationParameters, ConnectionTokenSet, - AccessTokenForConnectionOptions, LogoutToken, SessionData, StartInteractiveLoginOptions, @@ -65,7 +65,6 @@ const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"].join( " " ); - /** * A constant representing the grant type for federated connection access token exchange. * @@ -1016,19 +1015,20 @@ export class AuthClient { tokenSet: TokenSet, connectionTokenSet: ConnectionTokenSet | undefined, options: AccessTokenForConnectionOptions - ): Promise<[AccessTokenForConnectionError, null] | [null, ConnectionTokenSet]> { + ): Promise< + [AccessTokenForConnectionError, null] | [null, ConnectionTokenSet] + > { // If we do not have a refresh token // and we do not have a connection token set in the cache or the one we have is expired, // there is noting to retrieve and we return an error. if ( !tokenSet.refreshToken && - (!connectionTokenSet || - connectionTokenSet.expiresAt <= Date.now() / 1000) + (!connectionTokenSet || connectionTokenSet.expiresAt <= Date.now() / 1000) ) { return [ new AccessTokenForConnectionError( AccessTokenForConnectionErrorCode.MISSING_REFRESH_TOKEN, - "A refresh token was not present, Connection Access Token requires a refresh token. The user needs to re-authenticate.", + "A refresh token was not present, Connection Access Token requires a refresh token. The user needs to re-authenticate." ), null ]; @@ -1039,8 +1039,7 @@ export class AuthClient { // we need to exchange the refresh token for a connection access token. if ( tokenSet.refreshToken && - (!connectionTokenSet || - connectionTokenSet.expiresAt <= Date.now() / 1000) + (!connectionTokenSet || connectionTokenSet.expiresAt <= Date.now() / 1000) ) { const params = new URLSearchParams(); @@ -1111,10 +1110,7 @@ export class AuthClient { ]; } - return [null, connectionTokenSet] as [ - null, - ConnectionTokenSet - ]; + return [null, connectionTokenSet] as [null, ConnectionTokenSet]; } } diff --git a/src/server/chunked-cookies.test.ts b/src/server/chunked-cookies.test.ts index aba497bf0..e17aec65e 100644 --- a/src/server/chunked-cookies.test.ts +++ b/src/server/chunked-cookies.test.ts @@ -231,7 +231,7 @@ describe("Chunked Cookie Utils", () => { // It is called 3 times. // 2 times for the chunks // 1 time for the non chunked cookie - expect(reqCookies.delete).toHaveBeenCalledTimes(3); + expect(reqCookies.delete).toHaveBeenCalledTimes(3); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__3`); expect(reqCookies.delete).toHaveBeenCalledWith(`${name}__4`); expect(reqCookies.delete).toHaveBeenCalledWith(name); diff --git a/src/server/cookies.ts b/src/server/cookies.ts index 635942d95..3418540b5 100644 --- a/src/server/cookies.ts +++ b/src/server/cookies.ts @@ -187,7 +187,7 @@ export function setChunkedCookie( reqCookies.set(name, value); // When we are writing a non-chunked cookie, we should remove the chunked cookies - getAllChunkedCookies(reqCookies, name).forEach(cookieChunk => { + getAllChunkedCookies(reqCookies, name).forEach((cookieChunk) => { resCookies.delete(cookieChunk.name); reqCookies.delete(cookieChunk.name); }); @@ -223,9 +223,9 @@ export function setChunkedCookie( } } - // When we have written chunked cookies, we should remove the non-chunked cookie - resCookies.delete(name); - reqCookies.delete(name); + // When we have written chunked cookies, we should remove the non-chunked cookie + resCookies.delete(name); + reqCookies.delete(name); } /** diff --git a/src/server/session/stateful-session-store.test.ts b/src/server/session/stateful-session-store.test.ts index fab837bcd..6e690756d 100644 --- a/src/server/session/stateful-session-store.test.ts +++ b/src/server/session/stateful-session-store.test.ts @@ -690,7 +690,6 @@ describe("Stateful Session Store", async () => { }); }); - it("should remove the legacy cookie if it exists", async () => { const currentTime = Date.now(); const createdAt = Math.floor(currentTime / 1000); @@ -718,7 +717,7 @@ describe("Stateful Session Store", async () => { const sessionStore = new StatefulSessionStore({ secret, - store, + store }); vi.spyOn(requestCookies, "has").mockReturnValue(true); diff --git a/src/server/session/stateless-session-store.test.ts b/src/server/session/stateless-session-store.test.ts index 24a427ed1..23f853d70 100644 --- a/src/server/session/stateless-session-store.test.ts +++ b/src/server/session/stateless-session-store.test.ts @@ -334,7 +334,7 @@ describe("Stateless Session Store", async () => { const responseCookies = new ResponseCookies(new Headers()); const sessionStore = new StatelessSessionStore({ - secret, + secret }); vi.spyOn(responseCookies, "delete"); @@ -365,19 +365,23 @@ describe("Stateless Session Store", async () => { const responseCookies = new ResponseCookies(new Headers()); const sessionStore = new StatelessSessionStore({ - secret, + secret }); vi.spyOn(responseCookies, "delete"); vi.spyOn(requestCookies, "getAll").mockReturnValue([ - { name: `${LEGACY_COOKIE_NAME}__0`, value: '' }, - { name: `${LEGACY_COOKIE_NAME}__1`, value: '' } + { name: `${LEGACY_COOKIE_NAME}__0`, value: "" }, + { name: `${LEGACY_COOKIE_NAME}__1`, value: "" } ]); await sessionStore.set(requestCookies, responseCookies, session); - expect(responseCookies.delete).toHaveBeenCalledWith(`${LEGACY_COOKIE_NAME}__0`); - expect(responseCookies.delete).toHaveBeenCalledWith(`${LEGACY_COOKIE_NAME}__1`); + expect(responseCookies.delete).toHaveBeenCalledWith( + `${LEGACY_COOKIE_NAME}__0` + ); + expect(responseCookies.delete).toHaveBeenCalledWith( + `${LEGACY_COOKIE_NAME}__1` + ); }); }); @@ -516,7 +520,7 @@ describe("Stateless Session Store", async () => { const sessionStore = new StatelessSessionStore({ secret, cookieOptions: { - path: '/custom-path' + path: "/custom-path" } }); await sessionStore.set(requestCookies, responseCookies, session); diff --git a/src/server/session/stateless-session-store.ts b/src/server/session/stateless-session-store.ts index bdf628aa4..2e8048303 100644 --- a/src/server/session/stateless-session-store.ts +++ b/src/server/session/stateless-session-store.ts @@ -1,7 +1,6 @@ -import { CookieOptions, ConnectionTokenSet, SessionData } from "../../types"; - import type { JWTPayload } from "jose"; +import { ConnectionTokenSet, CookieOptions, SessionData } from "../../types"; import * as cookies from "../cookies"; import { AbstractSessionStore, @@ -55,17 +54,14 @@ export class StatelessSessionStore extends AbstractSessionStore { SessionData | LegacySessionPayload >(cookieValue, this.secret); - const normalizedStatelessSession = normalizeStatelessSession(originalSession); + const normalizedStatelessSession = + normalizeStatelessSession(originalSession); // As connection access tokens are stored in seperate cookies, // we need to get all cookies and only use those that are prefixed with `this.connectionTokenSetsCookieName` const connectionTokenSets = await Promise.all( - this.getConnectionTokenSetsCookies(reqCookies).map( - (cookie) => - cookies.decrypt( - cookie.value, - this.secret - ) + this.getConnectionTokenSetsCookies(reqCookies).map((cookie) => + cookies.decrypt(cookie.value, this.secret) ) ); @@ -73,7 +69,11 @@ export class StatelessSessionStore extends AbstractSessionStore { ...normalizedStatelessSession, // Ensure that when there are no connection token sets, we omit the property. ...(connectionTokenSets.length - ? { connectionTokenSets: connectionTokenSets.map(tokenSet => tokenSet.payload) } + ? { + connectionTokenSets: connectionTokenSets.map( + (tokenSet) => tokenSet.payload + ) + } : {}) }; } @@ -117,7 +117,7 @@ export class StatelessSessionStore extends AbstractSessionStore { ) ); } - + // Any existing v3 cookie can be deleted as soon as we have set a v4 cookie. // In stateless sessions, we do have to ensure we delete all chunks. cookies.deleteChunkedCookie(LEGACY_COOKIE_NAME, reqCookies, resCookies); @@ -127,11 +127,7 @@ export class StatelessSessionStore extends AbstractSessionStore { reqCookies: cookies.RequestCookies, resCookies: cookies.ResponseCookies ) { - cookies.deleteChunkedCookie( - this.sessionCookieName, - reqCookies, - resCookies - ); + cookies.deleteChunkedCookie(this.sessionCookieName, reqCookies, resCookies); this.getConnectionTokenSetsCookies(reqCookies).forEach((cookie) => resCookies.delete(cookie.name) @@ -177,7 +173,6 @@ export class StatelessSessionStore extends AbstractSessionStore { "You can use a stateful session implementation to store the session data in a data store." ); } - } }