From 33d45568ec13f5641ae37902b5c8946764c0997e Mon Sep 17 00:00:00 2001 From: LordMathis Date: Thu, 29 May 2025 16:25:42 +0200 Subject: [PATCH] Add tests for AuthContext, ModalContext and ThemeContext --- app/package-lock.json | 515 ++++++++++++++- app/package.json | 4 +- app/src/contexts/AuthContext.test.tsx | 825 +++++++++++++++++++++++++ app/src/contexts/ModalContext.test.tsx | 658 ++++++++++++++++++++ app/src/contexts/ThemeContext.test.tsx | 401 ++++++++++++ app/src/contexts/ThemeContext.tsx | 7 +- 6 files changed, 2406 insertions(+), 4 deletions(-) create mode 100644 app/src/contexts/AuthContext.test.tsx create mode 100644 app/src/contexts/ModalContext.test.tsx create mode 100644 app/src/contexts/ThemeContext.test.tsx diff --git a/app/package-lock.json b/app/package-lock.json index bbb9f3c..cbc2d1c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -47,6 +47,7 @@ "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.1.4", "eslint": "^9.27.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", @@ -396,6 +397,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/@codemirror/commands": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.2.tgz", @@ -1465,6 +1476,34 @@ "url": "https://github.com/sponsors/nzakas" } }, + "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/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -1987,6 +2026,17 @@ "url": "https://opencollective.com/parcel" } }, + "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/@react-dnd/asap": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz", @@ -2861,6 +2911,39 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.4.tgz", + "integrity": "sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "debug": "^4.4.0", + "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.1.4", + "vitest": "3.1.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz", @@ -3039,7 +3122,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4035,6 +4117,13 @@ "node": ">= 0.4" } }, + "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.113", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz", @@ -4042,6 +4131,13 @@ "dev": true, "license": "ISC" }, + "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/entities": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", @@ -4775,6 +4871,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.2", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", @@ -4927,6 +5040,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "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": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -5370,6 +5504,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", @@ -5687,6 +5828,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", @@ -5934,6 +6085,60 @@ "dev": true, "license": "ISC" }, + "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-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.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "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", @@ -5952,6 +6157,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/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6226,6 +6447,47 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "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-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.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6968,6 +7230,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mj-context-menu": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.6.1.tgz", @@ -7214,6 +7486,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -7292,6 +7571,30 @@ "dev": true, "license": "MIT" }, + "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/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -8695,6 +8998,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8776,6 +9092,60 @@ "dev": true, "license": "MIT" }, + "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/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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -8888,6 +9258,49 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "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-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -9012,6 +9425,21 @@ "node": ">=10" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -10107,6 +10535,91 @@ "node": ">=0.10.0" } }, + "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/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/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", diff --git a/app/package.json b/app/package.json index 4daf93e..de095f1 100644 --- a/app/package.json +++ b/app/package.json @@ -11,7 +11,8 @@ "lint": "eslint . --ext .ts,.tsx", "lint:fix": "eslint . --ext .ts,.tsx --fix", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "coverage": "vitest run --coverage" }, "repository": { "type": "git", @@ -66,6 +67,7 @@ "@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/parser": "^8.32.1", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.1.4", "eslint": "^9.27.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/app/src/contexts/AuthContext.test.tsx b/app/src/contexts/AuthContext.test.tsx new file mode 100644 index 0000000..04a152c --- /dev/null +++ b/app/src/contexts/AuthContext.test.tsx @@ -0,0 +1,825 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import React from 'react'; +import { AuthProvider, useAuth } from './AuthContext'; +import { UserRole, type User } from '@/types/models'; + +// Set up mocks before imports are used +vi.mock('@/api/auth', () => { + return { + login: vi.fn(), + logout: vi.fn(), + refreshToken: vi.fn(), + getCurrentUser: vi.fn(), + }; +}); + +vi.mock('@mantine/notifications', () => { + return { + notifications: { + show: vi.fn(), + }, + }; +}); + +// Import the mocks after they've been defined +import { + login as mockLogin, + logout as mockLogout, + refreshToken as mockRefreshToken, + getCurrentUser as mockGetCurrentUser, +} from '@/api/auth'; +import { notifications } from '@mantine/notifications'; + +// Get reference to the mocked notifications.show function +const mockNotificationsShow = notifications.show as unknown as ReturnType< + typeof vi.fn +>; + +// Mock user data +const mockUser: User = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, +}; + +// Helper wrapper component for testing +const createWrapper = () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'AuthProviderTestWrapper'; + return Wrapper; +}; + +describe('AuthContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('AuthProvider initialization', () => { + it('initializes with null user and loading state', () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + expect(result.current.user).toBeNull(); + expect(result.current.loading).toBe(true); + expect(result.current.initialized).toBe(false); + }); + + it('provides all expected functions', () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + expect(typeof result.current.login).toBe('function'); + expect(typeof result.current.logout).toBe('function'); + expect(typeof result.current.refreshToken).toBe('function'); + expect(typeof result.current.refreshUser).toBe('function'); + }); + + it('loads current user on mount when authenticated', async () => { + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + expect(result.current.user).toEqual(mockUser); + expect(result.current.loading).toBe(false); + expect(mockGetCurrentUser).toHaveBeenCalledTimes(1); + }); + + it('handles initialization error gracefully', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Network error') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + expect(result.current.user).toBeNull(); + expect(result.current.loading).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to initialize auth:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('useAuth hook', () => { + it('throws error when used outside AuthProvider', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => { + renderHook(() => useAuth()); + }).toThrow('useAuth must be used within an AuthProvider'); + + consoleSpy.mockRestore(); + }); + + it('returns auth context when used within provider', async () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('object'); + }); + + it('maintains function stability across re-renders', async () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + + const wrapper = createWrapper(); + const { result, rerender } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + const initialFunctions = { + login: result.current.login, + logout: result.current.logout, + refreshToken: result.current.refreshToken, + refreshUser: result.current.refreshUser, + }; + + rerender(); + + expect(result.current.login).toBe(initialFunctions.login); + expect(result.current.logout).toBe(initialFunctions.logout); + expect(result.current.refreshToken).toBe(initialFunctions.refreshToken); + expect(result.current.refreshUser).toBe(initialFunctions.refreshUser); + }); + }); + + describe('login functionality', () => { + beforeEach(() => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + }); + + it('logs in user successfully', async () => { + (mockLogin as ReturnType).mockResolvedValue(mockUser); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + let loginResult: boolean | undefined; + await act(async () => { + loginResult = await result.current.login( + 'test@example.com', + 'password123' + ); + }); + + expect(loginResult).toBe(true); + expect(result.current.user).toEqual(mockUser); + expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password123'); + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Success', + message: 'Logged in successfully', + color: 'green', + }); + }); + + it('handles login failure with error message', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + (mockLogin as ReturnType).mockRejectedValue( + new Error('Invalid credentials') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + let loginResult: boolean | undefined; + await act(async () => { + loginResult = await result.current.login( + 'test@example.com', + 'wrongpassword' + ); + }); + + expect(loginResult).toBe(false); + expect(result.current.user).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Login failed:', + expect.any(Error) + ); + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Invalid credentials', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('handles login failure with generic message', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + (mockLogin as ReturnType).mockRejectedValue( + 'Network error' + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + let loginResult: boolean | undefined; + await act(async () => { + loginResult = await result.current.login( + 'test@example.com', + 'password123' + ); + }); + + expect(loginResult).toBe(false); + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Login failed', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('handles multiple login attempts', async () => { + (mockLogin as ReturnType) + .mockRejectedValueOnce(new Error('First attempt failed')) + .mockResolvedValueOnce(mockUser); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + // First attempt fails + let firstResult: boolean | undefined; + await act(async () => { + firstResult = await result.current.login( + 'test@example.com', + 'wrongpassword' + ); + }); + + expect(firstResult).toBe(false); + expect(result.current.user).toBeNull(); + + // Second attempt succeeds + let secondResult: boolean | undefined; + await act(async () => { + secondResult = await result.current.login( + 'test@example.com', + 'correctpassword' + ); + }); + + expect(secondResult).toBe(true); + expect(result.current.user).toEqual(mockUser); + }); + }); + + describe('logout functionality', () => { + it('logs out user successfully', async () => { + // Start with authenticated user + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + (mockLogout as ReturnType).mockResolvedValue(undefined); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + expect(mockLogout).toHaveBeenCalledTimes(1); + }); + + it('clears user state even when logout API fails', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + // Start with authenticated user + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + (mockLogout as ReturnType).mockRejectedValue( + new Error('Logout failed') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Logout failed:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + + it('handles logout when user is already null', async () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + (mockLogout as ReturnType).mockResolvedValue(undefined); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + expect(mockLogout).toHaveBeenCalledTimes(1); + }); + }); + + describe('refreshToken functionality', () => { + it('refreshes token successfully', async () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + (mockRefreshToken as ReturnType).mockResolvedValue(true); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + let refreshResult: boolean | undefined; + await act(async () => { + refreshResult = await result.current.refreshToken(); + }); + + expect(refreshResult).toBe(true); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('handles token refresh failure and logs out user', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + // Start with authenticated user + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + (mockRefreshToken as ReturnType).mockResolvedValue(false); + (mockLogout as ReturnType).mockResolvedValue(undefined); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + let refreshResult: boolean | undefined; + await act(async () => { + refreshResult = await result.current.refreshToken(); + }); + + expect(refreshResult).toBe(false); + expect(result.current.user).toBeNull(); + expect(mockLogout).toHaveBeenCalledTimes(1); + + consoleSpy.mockRestore(); + }); + + it('handles token refresh API error and logs out user', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + // Start with authenticated user + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + (mockRefreshToken as ReturnType).mockRejectedValue( + new Error('Refresh failed') + ); + (mockLogout as ReturnType).mockResolvedValue(undefined); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + let refreshResult: boolean | undefined; + await act(async () => { + refreshResult = await result.current.refreshToken(); + }); + + expect(refreshResult).toBe(false); + expect(result.current.user).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Token refresh failed:', + expect.any(Error) + ); + expect(mockLogout).toHaveBeenCalledTimes(1); + + consoleSpy.mockRestore(); + }); + }); + + describe('refreshUser functionality', () => { + it('refreshes user data successfully', async () => { + // Start with authenticated user + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + // Mock updated user data + const updatedUser = { ...mockUser, displayName: 'Updated User' }; + (mockGetCurrentUser as ReturnType).mockResolvedValue( + updatedUser + ); + + await act(async () => { + await result.current.refreshUser(); + }); + + expect(result.current.user).toEqual(updatedUser); + expect(mockGetCurrentUser).toHaveBeenCalledTimes(2); // Once on init, once on refresh + }); + + it('handles user refresh failure', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + // Start with authenticated user + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + // Mock refresh failure + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Refresh user failed') + ); + + await act(async () => { + await result.current.refreshUser(); + }); + + // User should remain the same after failed refresh + expect(result.current.user).toEqual(mockUser); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to refresh user data:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('authentication state transitions', () => { + it('transitions from unauthenticated to authenticated', async () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + (mockLogin as ReturnType).mockResolvedValue(mockUser); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + expect(result.current.user).toBeNull(); + + await act(async () => { + await result.current.login('test@example.com', 'password123'); + }); + + expect(result.current.user).toEqual(mockUser); + }); + + it('transitions from authenticated to unauthenticated', async () => { + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + (mockLogout as ReturnType).mockResolvedValue(undefined); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + await act(async () => { + await result.current.logout(); + }); + + expect(result.current.user).toBeNull(); + }); + + it('handles user data updates while authenticated', async () => { + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.user).toEqual(mockUser); + }); + + // Simulate user profile update + const updatedUser = { ...mockUser, displayName: 'Updated Name' }; + (mockGetCurrentUser as ReturnType).mockResolvedValue( + updatedUser + ); + + await act(async () => { + await result.current.refreshUser(); + }); + + expect(result.current.user).toEqual(updatedUser); + }); + }); + + describe('context value structure', () => { + it('provides expected context interface', async () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + // Check boolean and object values + expect(result.current.user).toBeNull(); + expect(result.current.loading).toBe(false); + expect(result.current.initialized).toBe(true); + + // Check function types + expect(typeof result.current.login).toBe('function'); + expect(typeof result.current.logout).toBe('function'); + expect(typeof result.current.refreshToken).toBe('function'); + expect(typeof result.current.refreshUser).toBe('function'); + }); + + it('provides correct context when authenticated', async () => { + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + // Check boolean and object values + expect(result.current.user).toEqual(mockUser); + expect(result.current.loading).toBe(false); + expect(result.current.initialized).toBe(true); + + // Check function types + expect(typeof result.current.login).toBe('function'); + expect(typeof result.current.logout).toBe('function'); + expect(typeof result.current.refreshToken).toBe('function'); + expect(typeof result.current.refreshUser).toBe('function'); + }); + }); + + describe('loading states', () => { + it('shows loading during initialization', () => { + let resolveGetCurrentUser: (value: User) => void; + const pendingPromise = new Promise((resolve) => { + resolveGetCurrentUser = resolve; + }); + (mockGetCurrentUser as ReturnType).mockReturnValue( + pendingPromise + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + expect(result.current.loading).toBe(true); + expect(result.current.initialized).toBe(false); + + act(() => { + resolveGetCurrentUser!(mockUser); + }); + }); + + it('clears loading after initialization completes', async () => { + (mockGetCurrentUser as ReturnType).mockResolvedValue( + mockUser + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.initialized).toBe(true); + }); + }); + + it('clears loading after initialization fails', async () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Init failed') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.initialized).toBe(true); + }); + }); + }); + + describe('error handling', () => { + it('handles network errors during login', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + (mockLogin as ReturnType).mockRejectedValue( + new Error('Network unavailable') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + await act(async () => { + const success = await result.current.login( + 'test@example.com', + 'password123' + ); + expect(success).toBe(false); + }); + + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Network unavailable', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('handles invalid user data during initialization', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + // Use a more precise type for testing + (mockGetCurrentUser as ReturnType).mockResolvedValue({ + invalid: 'user', + } as unknown as User); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + expect(result.current.user).toEqual({ invalid: 'user' }); + + consoleSpy.mockRestore(); + }); + }); + + describe('concurrent operations', () => { + it('handles concurrent login attempts', async () => { + (mockGetCurrentUser as ReturnType).mockRejectedValue( + new Error('Not authenticated') + ); + (mockLogin as ReturnType).mockResolvedValue(mockUser); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + + // Make concurrent login calls + const [result1, result2] = await act(async () => { + return Promise.all([ + result.current.login('test@example.com', 'password123'), + result.current.login('test@example.com', 'password123'), + ]); + }); + + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(result.current.user).toEqual(mockUser); + expect(mockLogin).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/app/src/contexts/ModalContext.test.tsx b/app/src/contexts/ModalContext.test.tsx new file mode 100644 index 0000000..3bc5d18 --- /dev/null +++ b/app/src/contexts/ModalContext.test.tsx @@ -0,0 +1,658 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import React from 'react'; +import { ModalProvider, useModalContext } from './ModalContext'; + +// Helper wrapper component for testing +const createWrapper = () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'ModalProviderTestWrapper'; + return Wrapper; +}; + +describe('ModalContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('ModalProvider', () => { + it('provides modal context with initial false values', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + expect(result.current.newFileModalVisible).toBe(false); + expect(result.current.deleteFileModalVisible).toBe(false); + expect(result.current.commitMessageModalVisible).toBe(false); + expect(result.current.settingsModalVisible).toBe(false); + expect(result.current.switchWorkspaceModalVisible).toBe(false); + expect(result.current.createWorkspaceModalVisible).toBe(false); + }); + + it('provides all setter functions', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + expect(typeof result.current.setNewFileModalVisible).toBe('function'); + expect(typeof result.current.setDeleteFileModalVisible).toBe('function'); + expect(typeof result.current.setCommitMessageModalVisible).toBe( + 'function' + ); + expect(typeof result.current.setSettingsModalVisible).toBe('function'); + expect(typeof result.current.setSwitchWorkspaceModalVisible).toBe( + 'function' + ); + expect(typeof result.current.setCreateWorkspaceModalVisible).toBe( + 'function' + ); + }); + + it('provides complete context interface', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + const expectedKeys = [ + 'newFileModalVisible', + 'setNewFileModalVisible', + 'deleteFileModalVisible', + 'setDeleteFileModalVisible', + 'commitMessageModalVisible', + 'setCommitMessageModalVisible', + 'settingsModalVisible', + 'setSettingsModalVisible', + 'switchWorkspaceModalVisible', + 'setSwitchWorkspaceModalVisible', + 'createWorkspaceModalVisible', + 'setCreateWorkspaceModalVisible', + ]; + + expectedKeys.forEach((key) => { + expect(key in result.current).toBe(true); + }); + }); + }); + + describe('useModalContext hook', () => { + it('throws error when used outside ModalProvider', () => { + // Suppress console.error for this test since we expect an error + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => { + renderHook(() => useModalContext()); + }).toThrow('useModalContext must be used within a ModalProvider'); + + consoleSpy.mockRestore(); + }); + + it('returns modal context when used within provider', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('object'); + }); + + it('maintains function stability across re-renders', () => { + const wrapper = createWrapper(); + const { result, rerender } = renderHook(() => useModalContext(), { + wrapper, + }); + + const initialSetters = { + setNewFileModalVisible: result.current.setNewFileModalVisible, + setDeleteFileModalVisible: result.current.setDeleteFileModalVisible, + setCommitMessageModalVisible: + result.current.setCommitMessageModalVisible, + setSettingsModalVisible: result.current.setSettingsModalVisible, + setSwitchWorkspaceModalVisible: + result.current.setSwitchWorkspaceModalVisible, + setCreateWorkspaceModalVisible: + result.current.setCreateWorkspaceModalVisible, + }; + + rerender(); + + expect(result.current.setNewFileModalVisible).toBe( + initialSetters.setNewFileModalVisible + ); + expect(result.current.setDeleteFileModalVisible).toBe( + initialSetters.setDeleteFileModalVisible + ); + expect(result.current.setCommitMessageModalVisible).toBe( + initialSetters.setCommitMessageModalVisible + ); + expect(result.current.setSettingsModalVisible).toBe( + initialSetters.setSettingsModalVisible + ); + expect(result.current.setSwitchWorkspaceModalVisible).toBe( + initialSetters.setSwitchWorkspaceModalVisible + ); + expect(result.current.setCreateWorkspaceModalVisible).toBe( + initialSetters.setCreateWorkspaceModalVisible + ); + }); + }); + + describe('modal state management', () => { + describe('newFileModalVisible', () => { + it('can be set to true', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setNewFileModalVisible(true); + }); + + expect(result.current.newFileModalVisible).toBe(true); + }); + + it('can be toggled back to false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setNewFileModalVisible(true); + }); + + act(() => { + result.current.setNewFileModalVisible(false); + }); + + expect(result.current.newFileModalVisible).toBe(false); + }); + + it('can be toggled multiple times', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setNewFileModalVisible(true); + }); + expect(result.current.newFileModalVisible).toBe(true); + + act(() => { + result.current.setNewFileModalVisible(false); + }); + expect(result.current.newFileModalVisible).toBe(false); + + act(() => { + result.current.setNewFileModalVisible(true); + }); + expect(result.current.newFileModalVisible).toBe(true); + }); + }); + + describe('deleteFileModalVisible', () => { + it('can be set to true', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setDeleteFileModalVisible(true); + }); + + expect(result.current.deleteFileModalVisible).toBe(true); + }); + + it('can be toggled back to false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setDeleteFileModalVisible(true); + }); + + act(() => { + result.current.setDeleteFileModalVisible(false); + }); + + expect(result.current.deleteFileModalVisible).toBe(false); + }); + }); + + describe('commitMessageModalVisible', () => { + it('can be set to true', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setCommitMessageModalVisible(true); + }); + + expect(result.current.commitMessageModalVisible).toBe(true); + }); + + it('can be toggled back to false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setCommitMessageModalVisible(true); + }); + + act(() => { + result.current.setCommitMessageModalVisible(false); + }); + + expect(result.current.commitMessageModalVisible).toBe(false); + }); + }); + + describe('settingsModalVisible', () => { + it('can be set to true', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setSettingsModalVisible(true); + }); + + expect(result.current.settingsModalVisible).toBe(true); + }); + + it('can be toggled back to false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setSettingsModalVisible(true); + }); + + act(() => { + result.current.setSettingsModalVisible(false); + }); + + expect(result.current.settingsModalVisible).toBe(false); + }); + }); + + describe('switchWorkspaceModalVisible', () => { + it('can be set to true', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setSwitchWorkspaceModalVisible(true); + }); + + expect(result.current.switchWorkspaceModalVisible).toBe(true); + }); + + it('can be toggled back to false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setSwitchWorkspaceModalVisible(true); + }); + + act(() => { + result.current.setSwitchWorkspaceModalVisible(false); + }); + + expect(result.current.switchWorkspaceModalVisible).toBe(false); + }); + }); + + describe('createWorkspaceModalVisible', () => { + it('can be set to true', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setCreateWorkspaceModalVisible(true); + }); + + expect(result.current.createWorkspaceModalVisible).toBe(true); + }); + + it('can be toggled back to false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setCreateWorkspaceModalVisible(true); + }); + + act(() => { + result.current.setCreateWorkspaceModalVisible(false); + }); + + expect(result.current.createWorkspaceModalVisible).toBe(false); + }); + }); + }); + + describe('independent modal state', () => { + it('each modal state is independent', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Set multiple modals to true + act(() => { + result.current.setNewFileModalVisible(true); + result.current.setDeleteFileModalVisible(true); + result.current.setSettingsModalVisible(true); + }); + + expect(result.current.newFileModalVisible).toBe(true); + expect(result.current.deleteFileModalVisible).toBe(true); + expect(result.current.settingsModalVisible).toBe(true); + expect(result.current.commitMessageModalVisible).toBe(false); + expect(result.current.switchWorkspaceModalVisible).toBe(false); + expect(result.current.createWorkspaceModalVisible).toBe(false); + }); + + it('setting one modal does not affect others', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Set all modals to true first + act(() => { + result.current.setNewFileModalVisible(true); + result.current.setDeleteFileModalVisible(true); + result.current.setCommitMessageModalVisible(true); + result.current.setSettingsModalVisible(true); + result.current.setSwitchWorkspaceModalVisible(true); + result.current.setCreateWorkspaceModalVisible(true); + }); + + // Toggle one modal off + act(() => { + result.current.setNewFileModalVisible(false); + }); + + expect(result.current.newFileModalVisible).toBe(false); + expect(result.current.deleteFileModalVisible).toBe(true); + expect(result.current.commitMessageModalVisible).toBe(true); + expect(result.current.settingsModalVisible).toBe(true); + expect(result.current.switchWorkspaceModalVisible).toBe(true); + expect(result.current.createWorkspaceModalVisible).toBe(true); + }); + }); + + describe('useState setter function behavior', () => { + it('handles function updater pattern', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Test function updater for toggling + act(() => { + result.current.setNewFileModalVisible((prev) => !prev); + }); + + expect(result.current.newFileModalVisible).toBe(true); + + act(() => { + result.current.setNewFileModalVisible((prev) => !prev); + }); + + expect(result.current.newFileModalVisible).toBe(false); + }); + + it('handles conditional updates with function updater', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Set to true first + act(() => { + result.current.setSettingsModalVisible(true); + }); + + // Use function updater with condition + act(() => { + result.current.setSettingsModalVisible((prev) => (prev ? false : true)); + }); + + expect(result.current.settingsModalVisible).toBe(false); + }); + + it('supports multiple rapid state updates', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + act(() => { + result.current.setNewFileModalVisible(true); + result.current.setNewFileModalVisible(false); + result.current.setNewFileModalVisible(true); + }); + + expect(result.current.newFileModalVisible).toBe(true); + }); + }); + + describe('provider nesting', () => { + it('inner provider creates independent context', () => { + const OuterWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const InnerWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useModalContext(), { + wrapper: InnerWrapper, + }); + + // Should work with nested providers (inner context takes precedence) + expect(result.current.newFileModalVisible).toBe(false); + + act(() => { + result.current.setNewFileModalVisible(true); + }); + + expect(result.current.newFileModalVisible).toBe(true); + }); + }); + + describe('context value structure', () => { + it('provides expected context interface', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + const expectedBooleanValues = { + newFileModalVisible: false, + deleteFileModalVisible: false, + commitMessageModalVisible: false, + settingsModalVisible: false, + switchWorkspaceModalVisible: false, + createWorkspaceModalVisible: false, + }; + + // Check the boolean values + Object.entries(expectedBooleanValues).forEach(([key, value]) => { + expect(result.current[key as keyof typeof result.current]).toBe(value); + }); + + // Check the setter functions exist + expect(typeof result.current.setNewFileModalVisible).toBe('function'); + expect(typeof result.current.setDeleteFileModalVisible).toBe('function'); + expect(typeof result.current.setCommitMessageModalVisible).toBe( + 'function' + ); + expect(typeof result.current.setSettingsModalVisible).toBe('function'); + expect(typeof result.current.setSwitchWorkspaceModalVisible).toBe( + 'function' + ); + expect(typeof result.current.setCreateWorkspaceModalVisible).toBe( + 'function' + ); + }); + + it('all boolean values have correct types', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + expect(typeof result.current.newFileModalVisible).toBe('boolean'); + expect(typeof result.current.deleteFileModalVisible).toBe('boolean'); + expect(typeof result.current.commitMessageModalVisible).toBe('boolean'); + expect(typeof result.current.settingsModalVisible).toBe('boolean'); + expect(typeof result.current.switchWorkspaceModalVisible).toBe('boolean'); + expect(typeof result.current.createWorkspaceModalVisible).toBe('boolean'); + }); + + it('all setter functions have correct types', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + expect(typeof result.current.setNewFileModalVisible).toBe('function'); + expect(typeof result.current.setDeleteFileModalVisible).toBe('function'); + expect(typeof result.current.setCommitMessageModalVisible).toBe( + 'function' + ); + expect(typeof result.current.setSettingsModalVisible).toBe('function'); + expect(typeof result.current.setSwitchWorkspaceModalVisible).toBe( + 'function' + ); + expect(typeof result.current.setCreateWorkspaceModalVisible).toBe( + 'function' + ); + }); + }); + + describe('performance considerations', () => { + it('does not cause unnecessary re-renders', () => { + const wrapper = createWrapper(); + const { result, rerender } = renderHook(() => useModalContext(), { + wrapper, + }); + + const initialContext = result.current; + + // Re-render without changing anything + rerender(); + + // All function references should be stable + expect(result.current.setNewFileModalVisible).toBe( + initialContext.setNewFileModalVisible + ); + expect(result.current.setDeleteFileModalVisible).toBe( + initialContext.setDeleteFileModalVisible + ); + expect(result.current.setCommitMessageModalVisible).toBe( + initialContext.setCommitMessageModalVisible + ); + expect(result.current.setSettingsModalVisible).toBe( + initialContext.setSettingsModalVisible + ); + expect(result.current.setSwitchWorkspaceModalVisible).toBe( + initialContext.setSwitchWorkspaceModalVisible + ); + expect(result.current.setCreateWorkspaceModalVisible).toBe( + initialContext.setCreateWorkspaceModalVisible + ); + }); + + it('maintains setter function stability after state changes', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + const initialSetters = { + setNewFileModalVisible: result.current.setNewFileModalVisible, + setDeleteFileModalVisible: result.current.setDeleteFileModalVisible, + }; + + // Change some state + act(() => { + result.current.setNewFileModalVisible(true); + result.current.setDeleteFileModalVisible(true); + }); + + // Function references should still be the same + expect(result.current.setNewFileModalVisible).toBe( + initialSetters.setNewFileModalVisible + ); + expect(result.current.setDeleteFileModalVisible).toBe( + initialSetters.setDeleteFileModalVisible + ); + }); + }); + + describe('real-world usage patterns', () => { + it('supports common modal workflow patterns', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Typical workflow: open modal, perform action, close modal + act(() => { + result.current.setNewFileModalVisible(true); + }); + + expect(result.current.newFileModalVisible).toBe(true); + + // User performs action (file creation), then modal closes + act(() => { + result.current.setNewFileModalVisible(false); + }); + + expect(result.current.newFileModalVisible).toBe(false); + }); + + it('supports opening multiple modals in sequence', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Open new file modal + act(() => { + result.current.setNewFileModalVisible(true); + }); + + // Close new file modal, open settings + act(() => { + result.current.setNewFileModalVisible(false); + result.current.setSettingsModalVisible(true); + }); + + expect(result.current.newFileModalVisible).toBe(false); + expect(result.current.settingsModalVisible).toBe(true); + + // Close settings, open workspace creation + act(() => { + result.current.setSettingsModalVisible(false); + result.current.setCreateWorkspaceModalVisible(true); + }); + + expect(result.current.settingsModalVisible).toBe(false); + expect(result.current.createWorkspaceModalVisible).toBe(true); + }); + + it('supports modal state reset pattern', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Open multiple modals + act(() => { + result.current.setNewFileModalVisible(true); + result.current.setSettingsModalVisible(true); + result.current.setDeleteFileModalVisible(true); + }); + + // Reset all to false (like on route change or logout) + act(() => { + result.current.setNewFileModalVisible(false); + result.current.setSettingsModalVisible(false); + result.current.setDeleteFileModalVisible(false); + result.current.setCommitMessageModalVisible(false); + result.current.setSwitchWorkspaceModalVisible(false); + result.current.setCreateWorkspaceModalVisible(false); + }); + + expect(result.current.newFileModalVisible).toBe(false); + expect(result.current.settingsModalVisible).toBe(false); + expect(result.current.deleteFileModalVisible).toBe(false); + expect(result.current.commitMessageModalVisible).toBe(false); + expect(result.current.switchWorkspaceModalVisible).toBe(false); + expect(result.current.createWorkspaceModalVisible).toBe(false); + }); + }); +}); diff --git a/app/src/contexts/ThemeContext.test.tsx b/app/src/contexts/ThemeContext.test.tsx new file mode 100644 index 0000000..c14e9bd --- /dev/null +++ b/app/src/contexts/ThemeContext.test.tsx @@ -0,0 +1,401 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import React from 'react'; +import { ThemeProvider, useTheme } from './ThemeContext'; +import type { MantineColorScheme } from '@mantine/core'; + +// Mock Mantine's color scheme hook +const mockSetColorScheme = vi.fn(); +const mockUseMantineColorScheme = vi.fn(); + +vi.mock('@mantine/core', () => ({ + useMantineColorScheme: (): { + colorScheme: MantineColorScheme | undefined; + setColorScheme?: (scheme: MantineColorScheme) => void; + } => + mockUseMantineColorScheme() as { + colorScheme: MantineColorScheme | undefined; + setColorScheme?: (scheme: MantineColorScheme) => void; + }, +})); + +// Helper wrapper component for testing +const createWrapper = (initialColorScheme: MantineColorScheme = 'light') => { + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: initialColorScheme, + setColorScheme: mockSetColorScheme, + }); + + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'ThemeProviderTestWrapper'; + return Wrapper; +}; + +describe('ThemeContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('ThemeProvider', () => { + it('provides theme context with light scheme by default', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current.colorScheme).toBe('light'); + expect(typeof result.current.updateColorScheme).toBe('function'); + }); + + it('provides theme context with dark scheme', () => { + const wrapper = createWrapper('dark'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current.colorScheme).toBe('dark'); + expect(typeof result.current.updateColorScheme).toBe('function'); + }); + + it('provides theme context with auto scheme', () => { + const wrapper = createWrapper('auto'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current.colorScheme).toBe('auto'); + expect(typeof result.current.updateColorScheme).toBe('function'); + }); + + it('calls useMantineColorScheme hook', () => { + const wrapper = createWrapper('light'); + renderHook(() => useTheme(), { wrapper }); + + expect(mockUseMantineColorScheme).toHaveBeenCalled(); + }); + }); + + describe('useTheme hook', () => { + it('throws error when used outside ThemeProvider', () => { + // Suppress console.error for this test since we expect an error + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => { + renderHook(() => useTheme()); + }).toThrow('useTheme must be used within a ThemeProvider'); + + consoleSpy.mockRestore(); + }); + + it('returns current color scheme from Mantine', () => { + const wrapper = createWrapper('dark'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current.colorScheme).toBe('dark'); + }); + + it('provides updateColorScheme function', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(typeof result.current.updateColorScheme).toBe('function'); + }); + + it('maintains function stability across re-renders', () => { + const wrapper = createWrapper('light'); + const { result, rerender } = renderHook(() => useTheme(), { wrapper }); + + const initialUpdateFunction = result.current.updateColorScheme; + + rerender(); + + expect(result.current.updateColorScheme).toBe(initialUpdateFunction); + }); + }); + + describe('updateColorScheme functionality', () => { + it('calls setColorScheme when updateColorScheme is invoked', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + act(() => { + result.current.updateColorScheme('dark'); + }); + + expect(mockSetColorScheme).toHaveBeenCalledWith('dark'); + }); + + it('handles switching from light to dark', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + act(() => { + result.current.updateColorScheme('dark'); + }); + + expect(mockSetColorScheme).toHaveBeenCalledWith('dark'); + expect(mockSetColorScheme).toHaveBeenCalledTimes(1); + }); + + it('handles switching from dark to light', () => { + const wrapper = createWrapper('dark'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + act(() => { + result.current.updateColorScheme('light'); + }); + + expect(mockSetColorScheme).toHaveBeenCalledWith('light'); + expect(mockSetColorScheme).toHaveBeenCalledTimes(1); + }); + + it('handles switching to auto scheme', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + act(() => { + result.current.updateColorScheme('auto'); + }); + + expect(mockSetColorScheme).toHaveBeenCalledWith('auto'); + }); + + it('handles multiple color scheme changes', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + act(() => { + result.current.updateColorScheme('dark'); + }); + + act(() => { + result.current.updateColorScheme('auto'); + }); + + act(() => { + result.current.updateColorScheme('light'); + }); + + expect(mockSetColorScheme).toHaveBeenCalledTimes(3); + expect(mockSetColorScheme).toHaveBeenNthCalledWith(1, 'dark'); + expect(mockSetColorScheme).toHaveBeenNthCalledWith(2, 'auto'); + expect(mockSetColorScheme).toHaveBeenNthCalledWith(3, 'light'); + }); + + it('calls setColorScheme immediately without batching', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + // Multiple synchronous calls + act(() => { + result.current.updateColorScheme('dark'); + result.current.updateColorScheme('auto'); + result.current.updateColorScheme('light'); + }); + + expect(mockSetColorScheme).toHaveBeenCalledTimes(3); + }); + }); + + describe('color scheme reactivity', () => { + it('reflects color scheme changes from Mantine', () => { + // Start with light + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: 'light', + setColorScheme: mockSetColorScheme, + }); + + const wrapper = createWrapper('light'); + const { result, rerender } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current.colorScheme).toBe('light'); + + // Simulate Mantine color scheme change + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: 'dark', + setColorScheme: mockSetColorScheme, + }); + + rerender(); + + expect(result.current.colorScheme).toBe('dark'); + }); + + it('maintains function reference when color scheme changes', () => { + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: 'light', + setColorScheme: mockSetColorScheme, + }); + + const wrapper = createWrapper('light'); + const { result, rerender } = renderHook(() => useTheme(), { wrapper }); + + const initialUpdateFunction = result.current.updateColorScheme; + + // Change color scheme + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: 'dark', + setColorScheme: mockSetColorScheme, + }); + + rerender(); + + expect(result.current.updateColorScheme).toBe(initialUpdateFunction); + }); + }); + + describe('context value structure', () => { + it('provides expected context interface', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current).toEqual({ + colorScheme: 'light', + updateColorScheme: expect.any(Function) as unknown, + }); + }); + + it('context value has correct types', () => { + const wrapper = createWrapper('dark'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(typeof result.current.colorScheme).toBe('string'); + expect(typeof result.current.updateColorScheme).toBe('function'); + }); + }); + + describe('provider nesting', () => { + it('works with nested providers (inner provider takes precedence)', () => { + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: 'dark', + setColorScheme: mockSetColorScheme, + }); + + const NestedWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useTheme(), { + wrapper: NestedWrapper, + }); + + expect(result.current.colorScheme).toBe('dark'); + expect(typeof result.current.updateColorScheme).toBe('function'); + }); + }); + + describe('edge cases', () => { + it('handles undefined color scheme gracefully by falling back to light theme', () => { + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: undefined, + setColorScheme: mockSetColorScheme, + }); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useTheme(), { wrapper }); + + // Should fallback to 'light' theme rather than being undefined + expect(result.current.colorScheme).toBe('light'); + expect(typeof result.current.updateColorScheme).toBe('function'); + }); + + it('handles missing setColorScheme function', () => { + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: 'light', + setColorScheme: undefined, + }); + + const wrapper = createWrapper(); + + // Should not throw during render + expect(() => { + renderHook(() => useTheme(), { wrapper }); + }).not.toThrow(); + }); + + it('handles updateColorScheme with same color scheme', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + act(() => { + result.current.updateColorScheme('light'); // Same as current + }); + + expect(mockSetColorScheme).toHaveBeenCalledWith('light'); + }); + }); + + describe('integration with Mantine', () => { + it('properly integrates with useMantineColorScheme', () => { + const mockMantineHook = { + colorScheme: 'dark' as MantineColorScheme, + setColorScheme: mockSetColorScheme, + }; + + mockUseMantineColorScheme.mockReturnValue(mockMantineHook); + + const wrapper = createWrapper('dark'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current.colorScheme).toBe('dark'); + + act(() => { + result.current.updateColorScheme('light'); + }); + + expect(mockSetColorScheme).toHaveBeenCalledWith('light'); + }); + + it('reflects all Mantine color scheme options', () => { + const colorSchemes: MantineColorScheme[] = ['light', 'dark', 'auto']; + + colorSchemes.forEach((scheme) => { + mockUseMantineColorScheme.mockReturnValue({ + colorScheme: scheme, + setColorScheme: mockSetColorScheme, + }); + + const wrapper = createWrapper(scheme); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current.colorScheme).toBe(scheme); + }); + }); + }); + + describe('performance', () => { + it('does not cause unnecessary re-renders', () => { + const wrapper = createWrapper('light'); + const { result, rerender } = renderHook(() => useTheme(), { wrapper }); + + const initialResult = result.current; + + // Re-render without changing anything + rerender(); + + // Function reference should be stable + expect(result.current.updateColorScheme).toBe( + initialResult.updateColorScheme + ); + }); + + it('useCallback optimization works correctly', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + const updateFunction1 = result.current.updateColorScheme; + + // Trigger a re-render by calling updateColorScheme + act(() => { + result.current.updateColorScheme('dark'); + }); + + // Function should still be the same reference due to useCallback + expect(result.current.updateColorScheme).toBe(updateFunction1); + }); + }); +}); diff --git a/app/src/contexts/ThemeContext.tsx b/app/src/contexts/ThemeContext.tsx index 5ccb069..33a3a4d 100644 --- a/app/src/contexts/ThemeContext.tsx +++ b/app/src/contexts/ThemeContext.tsx @@ -22,13 +22,16 @@ export const ThemeProvider: React.FC = ({ children }) => { const updateColorScheme = useCallback( (newTheme: MantineColorScheme): void => { - setColorScheme(newTheme); + if (setColorScheme) { + setColorScheme(newTheme); + } }, [setColorScheme] ); + // Ensure colorScheme is never undefined by falling back to light theme const value: ThemeContextType = { - colorScheme, + colorScheme: colorScheme || 'light', updateColorScheme, };