diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000..2c8adba --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,39 @@ +name: Frontend Tests + +permissions: + contents: read + +on: + push: + branches: + - "*" + paths: + - "app/**" + pull_request: + branches: + - main + +jobs: + test: + name: Run Frontend Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./app + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: "./app/package-lock.json" + + - name: Install dependencies + run: npm ci + + - name: Run Vitest tests + run: npm test diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 9783498..b3e91a6 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -4,6 +4,8 @@ on: push: branches: - "*" + paths: + - "server/**" pull_request: branches: - main diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 9ee6610..6064889 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -7,6 +7,8 @@ on: push: branches: - "*" + paths: + - "app/**" pull_request: branches: - main diff --git a/README.md b/README.md index 056a7b1..6efa5fc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Lemma -![Build](https://github.com/lordmathis/lemma/actions/workflows/build-and-release.yml/badge.svg) ![Go Tests](https://github.com/lordmathis/lemma/actions/workflows/go-test.yml/badge.svg) ![Typescript Check](https://github.com/lordmathis/lemma/actions/workflows/typescript.yml/badge.svg) +![Build](https://github.com/lordmathis/lemma/actions/workflows/build-and-release.yml/badge.svg) ![Backend Tests](https://github.com/lordmathis/lemma/actions/workflows/go-test.yml/badge.svg) ![Frontend Tests](https://github.com/lordmathis/lemma/actions/workflows/frontend-tests.yml/badge.svg) Yet another markdown editor. Work in progress diff --git a/app/eslint.config.mjs b/app/eslint.config.mjs index b85bc40..ae3a06f 100644 --- a/app/eslint.config.mjs +++ b/app/eslint.config.mjs @@ -104,4 +104,11 @@ export default defineConfig([ '@typescript-eslint/no-non-null-assertion': 'warn', }, }, + // Override configuration for test files + { + files: ['**/*.test.ts', '**/*.test.tsx'], + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + }, + }, ]); diff --git a/app/package-lock.json b/app/package-lock.json index cdcbeb7..cbc2d1c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -37,6 +37,8 @@ }, "devDependencies": { "@eslint/compat": "^1.2.9", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@types/babel__core": "^7.20.5", "@types/node": "^22.14.0", "@types/react": "^18.3.20", @@ -45,18 +47,28 @@ "@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", + "jsdom": "^26.1.0", "postcss": "^8.4.47", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "sass": "^1.80.4", "typescript": "^5.8.2", "vite": "^6.2.4", - "vite-plugin-compression2": "^1.3.0" + "vite-plugin-compression2": "^1.3.0", + "vitest": "^3.1.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -71,6 +83,25 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/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==", + "license": "ISC" + }, "node_modules/@asamuzakjp/dom-selector": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", @@ -366,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", @@ -564,6 +605,116 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.3.tgz", + "integrity": "sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.9.tgz", + "integrity": "sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", + "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", + "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -1325,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", @@ -1847,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", @@ -2224,6 +2414,105 @@ "react": ">= 16" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2622,6 +2911,162 @@ "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", + "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.1.4", + "@vitest/utils": "3.1.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz", + "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz", + "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz", + "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz", + "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz", + "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.1.4", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -2646,13 +3091,10 @@ } }, "node_modules/agent-base": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", - "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, "engines": { "node": ">= 14" } @@ -2674,6 +3116,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2697,6 +3149,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -2833,6 +3295,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -2962,6 +3434,16 @@ "optional": true, "peer": true }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2985,7 +3467,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3063,6 +3544,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3120,6 +3618,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3299,6 +3807,13 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3313,23 +3828,18 @@ } }, "node_modules/cssstyle": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.1.0.tgz", - "integrity": "sha512-h66W1URKpBS5YMI/V8PyXvTMFT8SupJ1IzoIV8IeBC/ji8WVmrO8dGlTi+2dh6whmdk6BiKJLD/ZBkhWbcg6nA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.3.1.tgz", + "integrity": "sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==", "license": "MIT", "dependencies": { - "rrweb-cssom": "^0.7.1" + "@asamuzakjp/css-color": "^3.1.2", + "rrweb-cssom": "^0.8.0" }, "engines": { "node": ">=18" } }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", - "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", - "license": "MIT" - }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -3404,9 +3914,9 @@ } }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3421,9 +3931,9 @@ } }, "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "license": "MIT" }, "node_modules/decode-named-character-reference": { @@ -3439,6 +3949,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3565,6 +4085,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -3579,7 +4107,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -3590,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", @@ -3597,6 +4131,25 @@ "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", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.23.9", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", @@ -3667,7 +4220,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3677,7 +4229,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3711,11 +4262,17 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -3728,7 +4285,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4159,6 +4715,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4305,14 +4871,32 @@ "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.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" }, "engines": { @@ -4346,7 +4930,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4397,7 +4980,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -4431,7 +5013,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -4459,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", @@ -4503,7 +5105,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4575,7 +5176,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4588,7 +5188,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4604,7 +5203,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4906,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", @@ -4920,12 +5525,12 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", - "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -4988,6 +5593,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", @@ -5213,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", @@ -5460,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", @@ -5478,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", @@ -5498,38 +6193,38 @@ } }, "node_modules/jsdom": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", - "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/dom-selector": "^2.0.1", - "cssstyle": "^4.0.1", + "cssstyle": "^4.2.1", "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", + "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.2", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.6.0", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.3", + "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.16.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "canvas": "^2.11.2" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -5664,6 +6359,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5693,6 +6395,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" + }, "node_modules/lowlight": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", @@ -5717,11 +6426,72 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@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", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6434,6 +7204,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -6450,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", @@ -6514,6 +7304,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6689,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", @@ -6729,29 +7533,17 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "license": "MIT", "dependencies": { - "entities": "^4.4.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6779,6 +7571,47 @@ "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", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6962,6 +7795,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -6993,10 +7864,16 @@ } }, "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "license": "MIT" + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -7278,6 +8155,20 @@ "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -7470,6 +8361,67 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-mathjax/node_modules/jsdom": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", + "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^2.0.1", + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/rehype-mathjax/node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "license": "MIT" + }, + "node_modules/rehype-mathjax/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7721,9 +8673,9 @@ } }, "node_modules/rrweb-cssom": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", - "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "license": "MIT" }, "node_modules/run-parallel": { @@ -8039,6 +8991,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8106,6 +9078,74 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "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", @@ -8218,6 +9258,62 @@ "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", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8329,6 +9425,35 @@ "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", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", @@ -8374,6 +9499,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8388,24 +9563,22 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" + "node": ">=16" } }, "node_modules/tr46": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", - "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -9016,6 +10189,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz", + "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-compression2": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-1.3.0.tgz", @@ -9059,6 +10255,77 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz", + "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.1.4", + "@vitest/mocker": "3.1.4", + "@vitest/pretty-format": "^3.1.4", + "@vitest/runner": "3.1.4", + "@vitest/snapshot": "3.1.4", + "@vitest/spy": "3.1.4", + "@vitest/utils": "3.1.4", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.13", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.1.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.1.4", + "@vitest/ui": "3.1.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -9118,12 +10385,12 @@ } }, "node_modules/whatwg-url": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", - "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "license": "MIT", "dependencies": { - "tr46": "^5.0.0", + "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" }, "engines": { @@ -9235,6 +10502,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wicked-good-xpath": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", @@ -9251,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 663117a..de095f1 100644 --- a/app/package.json +++ b/app/package.json @@ -9,7 +9,10 @@ "preview": "vite preview", "type-check": "tsc --noEmit", "lint": "eslint . --ext .ts,.tsx", - "lint:fix": "eslint . --ext .ts,.tsx --fix" + "lint:fix": "eslint . --ext .ts,.tsx --fix", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "vitest run --coverage" }, "repository": { "type": "git", @@ -54,6 +57,8 @@ }, "devDependencies": { "@eslint/compat": "^1.2.9", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", "@types/babel__core": "^7.20.5", "@types/node": "^22.14.0", "@types/react": "^18.3.20", @@ -62,16 +67,19 @@ "@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", + "jsdom": "^26.1.0", "postcss": "^8.4.47", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", "sass": "^1.80.4", "typescript": "^5.8.2", "vite": "^6.2.4", - "vite-plugin-compression2": "^1.3.0" + "vite-plugin-compression2": "^1.3.0", + "vitest": "^3.1.4" }, "browserslist": { "production": [ diff --git a/app/src/api/admin.ts b/app/src/api/admin.ts index de9d72b..b38f483 100644 --- a/app/src/api/admin.ts +++ b/app/src/api/admin.ts @@ -7,10 +7,10 @@ import { apiCall } from './api'; import { isSystemStats, isUser, - isWorkspace, + isWorkspaceStats, type SystemStats, type User, - type Workspace, + type WorkspaceStats, } from '@/types/models'; const ADMIN_BASE_URL = `${API_BASE_URL}/admin`; @@ -101,18 +101,18 @@ export const updateUser = async ( /** * Fetches all workspaces from the API - * @returns {Promise} A promise that resolves to an array of workspaces + * @returns {Promise} A promise that resolves to an array of workspaces * @throws {Error} If the API call fails or returns an invalid response * */ -export const getWorkspaces = async (): Promise => { +export const getWorkspaces = async (): Promise => { const response = await apiCall(`${ADMIN_BASE_URL}/workspaces`); const data: unknown = await response.json(); if (!Array.isArray(data)) { throw new Error('Invalid workspaces response received from API'); } return data.map((workspace) => { - if (!isWorkspace(workspace)) { - throw new Error('Invalid workspace object received from API'); + if (!isWorkspaceStats(workspace)) { + throw new Error('Invalid workspace stats object received from API'); } return workspace; }); diff --git a/app/src/api/api.test.ts b/app/src/api/api.test.ts new file mode 100644 index 0000000..9b2f2c3 --- /dev/null +++ b/app/src/api/api.test.ts @@ -0,0 +1,580 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { apiCall } from './api'; + +// Mock the auth module - move this before any constants +vi.mock('./auth', () => { + return { + refreshToken: vi.fn(), + }; +}); + +// Get the mocked function after vi.mock +const mockRefreshToken = vi.mocked(await import('./auth')).refreshToken; + +// Mock fetch globally +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Helper to create mock Response objects +const createMockResponse = ( + status: number, + body: unknown = {}, + ok?: boolean +): Response => { + const response = { + status, + ok: ok !== undefined ? ok : status >= 200 && status < 300, + json: vi.fn().mockResolvedValue(body), + text: vi + .fn() + .mockResolvedValue( + typeof body === 'string' ? body : JSON.stringify(body) + ), + } as unknown as Response; + return response; +}; + +// Helper to set document.cookie +const setCookie = (name: string, value: string) => { + Object.defineProperty(document, 'cookie', { + writable: true, + value: `${name}=${encodeURIComponent(value)}`, + configurable: true, + }); +}; + +describe('apiCall', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Clear cookies + Object.defineProperty(document, 'cookie', { + writable: true, + value: '', + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('basic functionality', () => { + it('makes a successful GET request', async () => { + const mockResponseData = { success: true }; + mockFetch.mockResolvedValue(createMockResponse(200, mockResponseData)); + + const result = await apiCall('https://api.example.com/test'); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + expect(result.status).toBe(200); + }); + + it('makes a successful POST request with body', async () => { + const requestBody = { name: 'test' }; + const mockResponseData = { id: 1, name: 'test' }; + mockFetch.mockResolvedValue(createMockResponse(201, mockResponseData)); + + const result = await apiCall('https://api.example.com/create', { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { + method: 'POST', + body: JSON.stringify(requestBody), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + expect(result.status).toBe(201); + }); + + it('handles 204 No Content responses', async () => { + mockFetch.mockResolvedValue(createMockResponse(204, null, true)); + + const result = await apiCall('https://api.example.com/delete', { + method: 'DELETE', + }); + + expect(result.status).toBe(204); + }); + + it('preserves custom headers', async () => { + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/test', { + headers: { + 'Custom-Header': 'custom-value', + 'Content-Type': 'text/plain', + }, + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', { + credentials: 'include', + headers: { + 'Content-Type': 'text/plain', // Custom content type should override + 'Custom-Header': 'custom-value', + }, + }); + }); + }); + + describe('CSRF token handling', () => { + it('adds CSRF token to non-GET requests when token exists', async () => { + setCookie('csrf_token', 'test-csrf-token'); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/create', { + method: 'POST', + body: JSON.stringify({ test: 'data' }), + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { + method: 'POST', + body: JSON.stringify({ test: 'data' }), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'test-csrf-token', + }, + }); + }); + + it('omits CSRF token with GET methods', async () => { + setCookie('csrf_token', 'test-token'); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/test', { method: 'GET' }); + + // Check that CSRF token is not included in headers + const calledOptions = mockFetch.mock.calls?.[0]?.[1] as RequestInit; + expect(calledOptions['headers']).not.toHaveProperty('X-CSRF-Token'); + }); + + it('handles URL-encoded CSRF tokens', async () => { + const encodedToken = 'token%20with%20spaces'; + setCookie('csrf_token', encodedToken); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/create', { + method: 'POST', + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': encodedToken, // We shouldn't expect it to be decoded since our api.ts is not decoding it + }, + }); + }); + + it('handles missing CSRF token gracefully', async () => { + // No CSRF token in cookies + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/create', { + method: 'POST', + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + // No X-CSRF-Token header + }, + }); + }); + + it('handles multiple cookies and extracts CSRF token correctly', async () => { + Object.defineProperty(document, 'cookie', { + writable: true, + value: + 'session_id=abc123; csrf_token=my-csrf-token; other_cookie=value', + configurable: true, + }); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/create', { + method: 'POST', + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'my-csrf-token', + }, + }); + }); + + it('handles empty CSRF token value', async () => { + setCookie('csrf_token', ''); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/create', { + method: 'POST', + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/create', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + // No X-CSRF-Token header when token is empty + }, + }); + }); + }); + + describe('error handling', () => { + it('throws error for non-2xx status codes', async () => { + const errorResponse = { message: 'Bad Request' }; + mockFetch.mockResolvedValue( + createMockResponse(400, errorResponse, false) + ); + + await expect(apiCall('https://api.example.com/error')).rejects.toThrow( + 'Bad Request' + ); + }); + + it('throws generic error when no error message in response', async () => { + mockFetch.mockResolvedValue(createMockResponse(500, {}, false)); + + await expect(apiCall('https://api.example.com/error')).rejects.toThrow( + 'HTTP error! status: 500' + ); + }); + + it('handles malformed JSON error responses', async () => { + const mockResponse = { + status: 400, + ok: false, + json: vi.fn().mockRejectedValue(new Error('Invalid JSON')), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect(apiCall('https://api.example.com/error')).rejects.toThrow( + 'Invalid JSON' + ); + }); + + it('handles network errors', async () => { + const networkError = new Error('Network error'); + mockFetch.mockRejectedValue(networkError); + + await expect(apiCall('https://api.example.com/error')).rejects.toThrow( + 'Network error' + ); + }); + + it('handles timeout errors', async () => { + const timeoutError = new Error('Request timeout'); + mockFetch.mockRejectedValue(timeoutError); + + await expect(apiCall('https://api.example.com/slow')).rejects.toThrow( + 'Request timeout' + ); + }); + }); + + describe('authentication and token refresh', () => { + it('handles 401 response by attempting token refresh and retrying', async () => { + const successResponse = createMockResponse(200, { data: 'success' }); + + mockFetch + .mockResolvedValueOnce(createMockResponse(401, {}, false)) // First call fails with 401 + .mockResolvedValueOnce(successResponse); // Retry succeeds + + mockRefreshToken.mockResolvedValue(true); + + const result = await apiCall('https://api.example.com/protected'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + expect(result.status).toBe(200); + }); + + it('throws error when token refresh fails', async () => { + mockFetch.mockResolvedValue(createMockResponse(401, {}, false)); + mockRefreshToken.mockResolvedValue(false); + + await expect( + apiCall('https://api.example.com/protected') + ).rejects.toThrow('Authentication failed'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('does not attempt refresh for auth/refresh endpoint', async () => { + mockFetch.mockResolvedValue(createMockResponse(401, {}, false)); + + await expect( + apiCall('https://api.example.com/auth/refresh') + ).rejects.toThrow('Authentication failed'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockRefreshToken).not.toHaveBeenCalled(); + }); + + it('handles successful token refresh but failed retry', async () => { + mockFetch + .mockResolvedValueOnce(createMockResponse(401, {}, false)) // First call fails + .mockResolvedValueOnce( + createMockResponse(403, { message: 'Forbidden' }, false) + ); // Retry fails with different error + + mockRefreshToken.mockResolvedValue(true); + + await expect( + apiCall('https://api.example.com/protected') + ).rejects.toThrow('Forbidden'); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('handles token refresh throwing an error', async () => { + mockFetch.mockResolvedValue(createMockResponse(401, {}, false)); + mockRefreshToken.mockRejectedValue(new Error('Refresh failed')); + + await expect( + apiCall('https://api.example.com/protected') + ).rejects.toThrow('Refresh failed'); // The test should match the actual error from the mock + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('preserves original request options in retry', async () => { + const requestBody = { data: 'test' }; + const customHeaders = { 'Custom-Header': 'value' }; + + mockFetch + .mockResolvedValueOnce(createMockResponse(401, {}, false)) + .mockResolvedValueOnce(createMockResponse(200, { success: true })); + + mockRefreshToken.mockResolvedValue(true); + + await apiCall('https://api.example.com/protected', { + method: 'POST', + body: JSON.stringify(requestBody), + headers: customHeaders, + }); + + // Check that both calls had the same options + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith( + 1, + 'https://api.example.com/protected', + { + method: 'POST', + body: JSON.stringify(requestBody), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Custom-Header': 'value', + }, + } + ); + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + 'https://api.example.com/protected', + { + method: 'POST', + body: JSON.stringify(requestBody), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Custom-Header': 'value', + }, + } + ); + }); + }); + + describe('console logging', () => { + it('logs debug information for requests and responses', async () => { + const consoleSpy = vi + .spyOn(console, 'debug') + .mockImplementation(() => {}); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/test'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Making API call to: https://api.example.com/test' + ); + expect(consoleSpy).toHaveBeenCalledWith( + 'Response status: 200 for URL: https://api.example.com/test' + ); + + consoleSpy.mockRestore(); + }); + + it('logs errors when API calls fail', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const networkError = new Error('Network failure'); + mockFetch.mockRejectedValue(networkError); + + await expect(apiCall('https://api.example.com/error')).rejects.toThrow(); + + expect(consoleSpy).toHaveBeenCalledWith( + 'API call failed: Network failure' + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('request options handling', () => { + it('merges provided options with defaults', async () => { + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/test', { + method: 'PUT', + cache: 'no-cache' as RequestCache, + redirect: 'follow' as RequestRedirect, + }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', { + method: 'PUT', + cache: 'no-cache', + redirect: 'follow', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('handles undefined options parameter', async () => { + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/test'); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('handles empty options object', async () => { + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/test', {}); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + }); + + describe('HTTP methods', () => { + it('handles different HTTP methods correctly', async () => { + setCookie('csrf_token', 'test-token'); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + const methods = ['POST', 'PUT', 'PATCH', 'DELETE']; + + for (const method of methods) { + mockFetch.mockClear(); + + await apiCall('https://api.example.com/test', { method }); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', { + method, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'test-token', + }, + }); + } + }); + + it('defaults to GET method when method is omitted', async () => { + setCookie('csrf_token', 'test-token'); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall('https://api.example.com/test', {}); + + expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/test', { + method: undefined, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + // No CSRF token for undefined (GET) method + }, + }); + }); + }); + + describe('edge cases', () => { + it('handles very long URLs', async () => { + const longUrl = 'https://api.example.com/' + 'a'.repeat(2000); + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall(longUrl); + + expect(mockFetch).toHaveBeenCalledWith(longUrl, expect.any(Object)); + }); + + it('handles special characters in URL', async () => { + const urlWithSpecialChars = + 'https://api.example.com/test?param=value&other=test%20value'; + mockFetch.mockResolvedValue(createMockResponse(200, {})); + + await apiCall(urlWithSpecialChars); + + expect(mockFetch).toHaveBeenCalledWith( + urlWithSpecialChars, + expect.any(Object) + ); + }); + + it('handles null response body', async () => { + const mockResponse = { + status: 200, + ok: true, + json: vi.fn().mockResolvedValue(null), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await apiCall('https://api.example.com/test'); + + expect(result.status).toBe(200); + }); + + it('handles empty string response body', async () => { + const mockResponse = { + status: 200, + ok: true, + json: vi.fn().mockResolvedValue(''), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + const result = await apiCall('https://api.example.com/test'); + + expect(result.status).toBe(200); + }); + }); +}); diff --git a/app/src/api/file.ts b/app/src/api/file.ts index 08f4c4f..c85d3a8 100644 --- a/app/src/api/file.ts +++ b/app/src/api/file.ts @@ -40,6 +40,10 @@ export const lookupFileByName = async ( workspaceName: string, filename: string ): Promise => { + if (!filename || typeof filename !== 'string') { + throw new Error('Invalid filename provided for lookup'); + } + const response = await apiCall( `${API_BASE_URL}/workspaces/${encodeURIComponent( workspaceName diff --git a/app/src/components/auth/LoginPage.test.tsx b/app/src/components/auth/LoginPage.test.tsx new file mode 100644 index 0000000..02b3c47 --- /dev/null +++ b/app/src/components/auth/LoginPage.test.tsx @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import { AuthProvider } from '@/contexts/AuthContext'; +import LoginPage from './LoginPage'; + +// Mock the auth API functions +const mockApiLogin = vi.fn(); +const mockApiLogout = vi.fn(); +const mockApiRefreshToken = vi.fn(); +const mockGetCurrentUser = vi.fn(); + +vi.mock('@/api/auth', () => ({ + login: (...args: unknown[]): unknown => mockApiLogin(...args), + logout: (...args: unknown[]): unknown => mockApiLogout(...args), + refreshToken: (...args: unknown[]): unknown => mockApiRefreshToken(...args), + getCurrentUser: (...args: unknown[]): unknown => mockGetCurrentUser(...args), +})); + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +// Custom render function +const render = async (ui: React.ReactElement) => { + const result = rtlRender(ui, { wrapper: TestWrapper }); + + // Wait for AuthProvider initialization to complete + await waitFor(() => { + // The LoginPage should be rendered (indicates AuthProvider has initialized) + expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument(); + }); + + return result; +}; + +describe('LoginPage', () => { + let mockNotificationShow: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Get the mocked notification function + const { notifications } = await import('@mantine/notifications'); + mockNotificationShow = vi.mocked(notifications.show); + + // Setup default mock implementations + mockGetCurrentUser.mockRejectedValue(new Error('No user session')); + mockApiLogin.mockResolvedValue({ + id: 1, + email: 'test@example.com', + role: 'editor', + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }); + }); + + describe('Initial Render', () => { + it('renders the login form with all required elements', async () => { + await render(); + + // Check title and subtitle + expect(screen.getByText('Welcome to Lemma')).toBeInTheDocument(); + expect( + screen.getByText('Please sign in to continue') + ).toBeInTheDocument(); + + // Check form fields with correct attributes + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); + + expect(emailInput).toBeInTheDocument(); + expect(emailInput).toHaveAttribute('type', 'email'); + expect(emailInput).toHaveAttribute('placeholder', 'your@email.com'); + expect(emailInput).toBeRequired(); + + expect(passwordInput).toBeInTheDocument(); + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).toHaveAttribute('placeholder', 'Your password'); + expect(passwordInput).toBeRequired(); + + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toHaveAttribute('type', 'submit'); + expect(submitButton).not.toHaveAttribute('data-loading', 'true'); + }); + }); + + describe('Form Interaction', () => { + it('updates input values when user types', async () => { + await render(); + + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + + expect((emailInput as HTMLInputElement).value).toBe('test@example.com'); + expect((passwordInput as HTMLInputElement).value).toBe('password123'); + }); + + it('prevents form submission with empty fields due to HTML5 validation', async () => { + await render(); + + const submitButton = screen.getByTestId('login-button'); + fireEvent.click(submitButton); + + expect(mockApiLogin).not.toHaveBeenCalled(); + }); + }); + + describe('Form Submission', () => { + const fillAndSubmitForm = (email: string, password: string) => { + const emailInput = screen.getByTestId('email-input'); + const passwordInput = screen.getByTestId('password-input'); + const submitButton = screen.getByTestId('login-button'); + + fireEvent.change(emailInput, { target: { value: email } }); + fireEvent.change(passwordInput, { target: { value: password } }); + fireEvent.click(submitButton); + + return { emailInput, passwordInput, submitButton }; + }; + + it('calls login function with correct credentials on form submit', async () => { + await render(); + fillAndSubmitForm('test@example.com', 'password123'); + + await waitFor(() => { + expect(mockApiLogin).toHaveBeenCalledWith( + 'test@example.com', + 'password123' + ); + }); + }); + + it('shows loading state during login and resets after completion', async () => { + // Create a controlled promise for login + let resolveLogin: () => void; + const loginPromise = new Promise((resolve) => { + resolveLogin = resolve; + }); + mockApiLogin.mockReturnValue(loginPromise); + + await render(); + const { submitButton } = fillAndSubmitForm( + 'test@example.com', + 'password123' + ); + + // Check loading state appears + await waitFor(() => { + expect(submitButton).toHaveAttribute('data-loading', 'true'); + }); + + // Resolve the login and check loading state is removed + resolveLogin!(); + await waitFor(() => { + expect(submitButton).not.toHaveAttribute('data-loading', 'true'); + }); + }); + + it('handles login success with notification', async () => { + await render(); + fillAndSubmitForm('test@example.com', 'password123'); + + await waitFor(() => { + expect(mockApiLogin).toHaveBeenCalled(); + }); + + // Verify success notification is shown + await waitFor(() => { + expect(mockNotificationShow).toHaveBeenCalledWith({ + title: 'Success', + message: 'Logged in successfully', + color: 'green', + }); + }); + }); + + it('handles login errors gracefully with notification', async () => { + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const errorMessage = 'Invalid credentials'; + mockApiLogin.mockRejectedValue(new Error(errorMessage)); + + await render(); + const { submitButton } = fillAndSubmitForm( + 'test@example.com', + 'wrongpassword' + ); + + await waitFor(() => { + expect(mockApiLogin).toHaveBeenCalled(); + }); + + // Verify error is logged + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Login failed:', + expect.any(Error) + ); + }); + + // Verify error notification is shown + await waitFor(() => { + expect(mockNotificationShow).toHaveBeenCalledWith({ + title: 'Error', + message: errorMessage, + color: 'red', + }); + }); + + // Verify loading state is reset + await waitFor(() => { + expect(submitButton).not.toHaveAttribute('data-loading', 'true'); + }); + + consoleErrorSpy.mockRestore(); + }); + + it('handles special characters in credentials', async () => { + await render(); + + const specialEmail = 'user+test@example-domain.com'; + const specialPassword = 'P@ssw0rd!#$%'; + + fillAndSubmitForm(specialEmail, specialPassword); + + await waitFor(() => { + expect(mockApiLogin).toHaveBeenCalledWith( + specialEmail, + specialPassword + ); + }); + }); + }); +}); diff --git a/app/src/components/auth/LoginPage.tsx b/app/src/components/auth/LoginPage.tsx index ad45793..8475d49 100644 --- a/app/src/components/auth/LoginPage.tsx +++ b/app/src/components/auth/LoginPage.tsx @@ -37,11 +37,13 @@ const LoginPage: React.FC = () => { -
+ setEmail(event.currentTarget.value)} @@ -50,12 +52,13 @@ const LoginPage: React.FC = () => { setPassword(event.currentTarget.value)} /> - diff --git a/app/src/components/editor/ContentView.test.tsx b/app/src/components/editor/ContentView.test.tsx new file mode 100644 index 0000000..706192c --- /dev/null +++ b/app/src/components/editor/ContentView.test.tsx @@ -0,0 +1,223 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '../../test/utils'; +import ContentView from './ContentView'; +import { Theme } from '@/types/models'; + +// Mock child components +vi.mock('./Editor', () => ({ + default: ({ + content, + selectedFile, + }: { + content: string; + selectedFile: string; + }) => ( +
+ Editor - {selectedFile} - {content} +
+ ), +})); + +vi.mock('./MarkdownPreview', () => ({ + default: ({ content }: { content: string }) => ( +
Preview - {content}
+ ), +})); + +// Mock contexts +vi.mock('../../contexts/WorkspaceContext', () => ({ + useWorkspace: vi.fn(), +})); + +// Mock utils +vi.mock('../../utils/fileHelpers', () => ({ + getFileUrl: vi.fn( + (workspace: string, file: string) => `http://test.com/${workspace}/${file}` + ), + isImageFile: vi.fn(), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('ContentView', () => { + const mockHandleContentChange = vi.fn(); + const mockHandleSave = vi.fn(); + const mockHandleFileSelect = vi.fn(); + + const mockCurrentWorkspace = { + id: 1, + name: 'test-workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useWorkspace } = await import('../../contexts/WorkspaceContext'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: mockCurrentWorkspace, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const { isImageFile } = await import('../../utils/fileHelpers'); + vi.mocked(isImageFile).mockReturnValue(false); + }); + + it('shows no workspace message when no workspace selected', async () => { + const { useWorkspace } = await import('../../contexts/WorkspaceContext'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: null, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const { getByText } = render( + + + + ); + + expect(getByText('No workspace selected.')).toBeInTheDocument(); + }); + + it('shows no file message when no file selected', () => { + const { getByText } = render( + + + + ); + + expect(getByText('No file selected.')).toBeInTheDocument(); + }); + + it('renders editor when activeTab is source', () => { + const { getByTestId } = render( + + + + ); + + const editor = getByTestId('editor'); + expect(editor).toBeInTheDocument(); + expect(editor).toHaveTextContent('Editor - test.md - Test content'); + }); + + it('renders markdown preview when activeTab is preview', () => { + const { getByTestId } = render( + + + + ); + + const preview = getByTestId('markdown-preview'); + expect(preview).toBeInTheDocument(); + expect(preview).toHaveTextContent('Preview - # Test content'); + }); + + it('renders image preview for image files', async () => { + const { isImageFile } = await import('../../utils/fileHelpers'); + vi.mocked(isImageFile).mockReturnValue(true); + + const { container } = render( + + + + ); + + const imagePreview = container.querySelector('.image-preview'); + expect(imagePreview).toBeInTheDocument(); + + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute( + 'src', + 'http://test.com/test-workspace/image.png' + ); + expect(img).toHaveAttribute('alt', 'image.png'); + }); + + it('ignores activeTab for image files', async () => { + const { isImageFile } = await import('../../utils/fileHelpers'); + vi.mocked(isImageFile).mockReturnValue(true); + + const { container, queryByTestId } = render( + + + + ); + + // Should show image preview regardless of activeTab + const imagePreview = container.querySelector('.image-preview'); + expect(imagePreview).toBeInTheDocument(); + + // Should not render editor or markdown preview + expect(queryByTestId('editor')).not.toBeInTheDocument(); + expect(queryByTestId('markdown-preview')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/editor/MarkdownPreview.test.tsx b/app/src/components/editor/MarkdownPreview.test.tsx new file mode 100644 index 0000000..444ee3a --- /dev/null +++ b/app/src/components/editor/MarkdownPreview.test.tsx @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import MarkdownPreview from './MarkdownPreview'; +import { notifications } from '@mantine/notifications'; +import { Theme } from '../../types/models'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock useWorkspace hook +vi.mock('../../hooks/useWorkspace', () => ({ + useWorkspace: vi.fn(), +})); + +// Mock the remarkWikiLinks utility +vi.mock('../../utils/remarkWikiLinks', () => ({ + remarkWikiLinks: vi.fn(() => () => {}), +})); + +// Mock window.API_BASE_URL +Object.defineProperty(window, 'API_BASE_URL', { + value: 'http://localhost:3000', + writable: true, +}); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('MarkdownPreview', () => { + const mockHandleFileSelect = vi.fn(); + const mockNotificationsShow = vi.mocked(notifications.show); + + beforeEach(async () => { + vi.clearAllMocks(); + + // Setup useWorkspace mock + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: { + id: 1, + name: 'test-workspace', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + createdAt: '2023-01-01T00:00:00Z', + lastOpenedFilePath: '', + }, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + }); + + it('renders basic markdown content', async () => { + const content = '# Hello World\n\nThis is a test.'; + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Hello World')).toBeInTheDocument(); + expect(screen.getByText('This is a test.')).toBeInTheDocument(); + }); + }); + + it('renders code blocks with syntax highlighting', async () => { + const content = '```javascript\nconst hello = "world";\n```'; + + render( + + ); + + await waitFor(() => { + // Check for the code element containing the text pieces + const codeElement = screen.getByText((_, element) => { + return !!( + element?.tagName.toLowerCase() === 'code' && + element?.textContent?.includes('const') && + element?.textContent?.includes('hello') && + element?.textContent?.includes('world') + ); + }); + expect(codeElement).toBeInTheDocument(); + expect(codeElement.closest('pre')).toBeInTheDocument(); + }); + }); + + it('handles image loading errors gracefully', async () => { + const content = '![Test Image](invalid-image.jpg)'; + + render( + + ); + + await waitFor(() => { + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + + // Simulate image load error + fireEvent.error(img); + + expect(img).toHaveAttribute('alt', 'Failed to load image'); + }); + }); + + it('handles internal link clicks and calls handleFileSelect', async () => { + const content = '[Test Link](http://localhost:3000/internal/test-file.md)'; + + render( + + ); + + await waitFor(() => { + const link = screen.getByText('Test Link'); + expect(link).toBeInTheDocument(); + + fireEvent.click(link); + + expect(mockHandleFileSelect).toHaveBeenCalledWith('test-file.md'); + }); + }); + + it('shows notification for non-existent file links', async () => { + const content = + '[Missing File](http://localhost:3000/notfound/missing-file.md)'; + + render( + + ); + + await waitFor(() => { + const link = screen.getByText('Missing File'); + fireEvent.click(link); + + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'File Not Found', + message: 'The file "missing-file.md" does not exist.', + color: 'red', + }); + expect(mockHandleFileSelect).not.toHaveBeenCalled(); + }); + }); + + it('handles external links normally without interference', async () => { + const content = '[External Link](https://example.com)'; + + render( + + ); + + await waitFor(() => { + const link = screen.getByText('External Link'); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', 'https://example.com'); + + // Click should be prevented but no file selection should occur + fireEvent.click(link); + expect(mockHandleFileSelect).not.toHaveBeenCalled(); + expect(mockNotificationsShow).not.toHaveBeenCalled(); + }); + }); + + it('does not process content when no workspace is available', async () => { + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: null, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const content = '# Test Content'; + + render( + + ); + + // Should render empty content when no workspace + const markdownPreview = screen.getByTestId('markdown-preview'); + expect(markdownPreview).toBeEmptyDOMElement(); + }); + + it('handles empty content gracefully', async () => { + render( + + ); + + await waitFor(() => { + const markdownPreview = screen.getByTestId('markdown-preview'); + expect(markdownPreview).toBeInTheDocument(); + }); + }); + + it('updates content when markdown changes', async () => { + const { rerender } = render( + + ); + + await waitFor(() => { + expect(screen.getByText('First Content')).toBeInTheDocument(); + }); + + rerender( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Updated Content')).toBeInTheDocument(); + expect(screen.queryByText('First Content')).not.toBeInTheDocument(); + }); + }); + + it('handles markdown processing errors gracefully', async () => { + // Mock console.error to avoid noise in test output + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Create content that might cause processing issues + const problematicContent = '# Test\n\n```invalid-syntax\nbroken code\n```'; + + render( + + ); + + // Wait for async content processing to complete + await waitFor(() => { + // Should still render something even if processing has issues + const markdownPreview = screen.getByTestId('markdown-preview'); + expect(markdownPreview).toBeInTheDocument(); + }); + + consoleSpy.mockRestore(); + }); + + it('handles URL decoding for file paths correctly', async () => { + const encodedContent = + '[Test Link](http://localhost:3000/internal/test%20file%20with%20spaces.md)'; + + render( + + ); + + await waitFor(() => { + const link = screen.getByText('Test Link'); + fireEvent.click(link); + + expect(mockHandleFileSelect).toHaveBeenCalledWith( + 'test file with spaces.md' + ); + }); + }); +}); diff --git a/app/src/components/editor/MarkdownPreview.tsx b/app/src/components/editor/MarkdownPreview.tsx index 83e4c4b..463f4b2 100644 --- a/app/src/components/editor/MarkdownPreview.tsx +++ b/app/src/components/editor/MarkdownPreview.tsx @@ -135,7 +135,11 @@ const MarkdownPreview: React.FC = ({ void processContent(); }, [content, processor, currentWorkspace]); - return
{processedContent}
; + return ( +
+ {processedContent} +
+ ); }; export default MarkdownPreview; diff --git a/app/src/components/files/FileActions.test.tsx b/app/src/components/files/FileActions.test.tsx new file mode 100644 index 0000000..501a0d8 --- /dev/null +++ b/app/src/components/files/FileActions.test.tsx @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent } from '@testing-library/react'; +import { render } from '../../test/utils'; +import FileActions from './FileActions'; +import { Theme } from '@/types/models'; + +// Mock the contexts and hooks +vi.mock('../../contexts/ModalContext', () => ({ + useModalContext: vi.fn(), +})); + +vi.mock('../../hooks/useWorkspace', () => ({ + useWorkspace: vi.fn(), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('FileActions', () => { + const mockHandlePullChanges = vi.fn(); + const mockSetNewFileModalVisible = vi.fn(); + const mockSetDeleteFileModalVisible = vi.fn(); + const mockSetCommitMessageModalVisible = vi.fn(); + + const mockCurrentWorkspace = { + id: 1, + name: 'Test Workspace', + createdAt: '2024-01-01T00:00:00Z', + gitEnabled: true, + gitAutoCommit: false, + theme: Theme.Light, + autoSave: true, + showHiddenFiles: false, + gitUrl: '', + gitBranch: 'main', + gitUsername: '', + gitEmail: '', + gitToken: '', + gitUser: '', + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useModalContext } = await import('../../contexts/ModalContext'); + vi.mocked(useModalContext).mockReturnValue({ + newFileModalVisible: false, + setNewFileModalVisible: mockSetNewFileModalVisible, + deleteFileModalVisible: false, + setDeleteFileModalVisible: mockSetDeleteFileModalVisible, + commitMessageModalVisible: false, + setCommitMessageModalVisible: mockSetCommitMessageModalVisible, + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: vi.fn(), + }); + + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: mockCurrentWorkspace, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + }); + + it('opens new file modal when create button is clicked', () => { + const { getByTestId } = render( + + + + ); + + const createButton = getByTestId('create-file-button'); + fireEvent.click(createButton); + + expect(mockSetNewFileModalVisible).toHaveBeenCalledWith(true); + }); + + it('opens delete modal when delete button is clicked with selected file', () => { + const { getByTestId } = render( + + + + ); + + const deleteButton = getByTestId('delete-file-button'); + fireEvent.click(deleteButton); + + expect(mockSetDeleteFileModalVisible).toHaveBeenCalledWith(true); + }); + + it('disables delete button when no file is selected', () => { + const { getByTestId } = render( + + + + ); + + const deleteButton = getByTestId('delete-file-button'); + expect(deleteButton).toBeDisabled(); + }); + + it('calls pull changes when pull button is clicked', () => { + mockHandlePullChanges.mockResolvedValue(true); + + const { getByTestId } = render( + + + + ); + + const pullButton = getByTestId('pull-changes-button'); + fireEvent.click(pullButton); + + expect(mockHandlePullChanges).toHaveBeenCalledOnce(); + }); + + it('disables git buttons when git is not enabled', async () => { + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: { ...mockCurrentWorkspace, gitEnabled: false }, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const { getByTestId } = render( + + + + ); + + const pullButton = getByTestId('pull-changes-button'); + expect(pullButton).toBeDisabled(); + + const commitButton = getByTestId('commit-push-button'); + expect(commitButton).toBeDisabled(); + }); + + it('opens commit modal when commit button is clicked', () => { + const { getByTestId } = render( + + + + ); + + const commitButton = getByTestId('commit-push-button'); + fireEvent.click(commitButton); + + expect(mockSetCommitMessageModalVisible).toHaveBeenCalledWith(true); + }); + + it('disables commit button when auto-commit is enabled', async () => { + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: { ...mockCurrentWorkspace, gitAutoCommit: true }, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const { getByTestId } = render( + + + + ); + + const commitButton = getByTestId('commit-push-button'); + expect(commitButton).toBeDisabled(); + }); +}); diff --git a/app/src/components/files/FileActions.tsx b/app/src/components/files/FileActions.tsx index 51ff89a..a3f0075 100644 --- a/app/src/components/files/FileActions.tsx +++ b/app/src/components/files/FileActions.tsx @@ -18,7 +18,7 @@ const FileActions: React.FC = ({ handlePullChanges, selectedFile, }) => { - const { settings } = useWorkspace(); + const { currentWorkspace } = useWorkspace(); const { setNewFileModalVisible, setDeleteFileModalVisible, @@ -32,7 +32,13 @@ const FileActions: React.FC = ({ return ( - + @@ -46,6 +52,8 @@ const FileActions: React.FC = ({ onClick={handleDeleteFile} disabled={!selectedFile} color="red" + aria-label="Delete current file" + data-testid="delete-file-button" > @@ -53,7 +61,7 @@ const FileActions: React.FC = ({ = ({ console.error('Error pulling changes:', error); }); }} - disabled={!settings.gitEnabled} + disabled={!currentWorkspace?.gitEnabled} + aria-label="Pull changes from remote" + data-testid="pull-changes-button" > @@ -74,9 +84,9 @@ const FileActions: React.FC = ({ = ({ variant="default" size="md" onClick={handleCommitAndPush} - disabled={!settings.gitEnabled || settings.gitAutoCommit} + disabled={ + !currentWorkspace?.gitEnabled || currentWorkspace.gitAutoCommit + } + aria-label="Commit and push changes" + data-testid="commit-push-button" > diff --git a/app/src/components/files/FileTree.test.tsx b/app/src/components/files/FileTree.test.tsx new file mode 100644 index 0000000..09ec508 --- /dev/null +++ b/app/src/components/files/FileTree.test.tsx @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../test/utils'; +import FileTree from './FileTree'; +import type { FileNode } from '../../types/models'; + +// Mock react-arborist +vi.mock('react-arborist', () => ({ + Tree: ({ + children, + data, + onActivate, + }: { + children: (props: { + node: { + data: FileNode; + isLeaf: boolean; + isInternal: boolean; + isOpen: boolean; + level: number; + toggle: () => void; + }; + style: Record; + onNodeClick: (node: { isInternal: boolean }) => void; + }) => React.ReactNode; + data: FileNode[]; + onActivate: (node: { isInternal: boolean; data: FileNode }) => void; + }) => ( +
+ {data.map((file) => { + const mockNode = { + data: file, + isLeaf: !file.children || file.children.length === 0, + isInternal: !!(file.children && file.children.length > 0), + isOpen: false, + level: 0, + toggle: vi.fn(), + }; + + return ( +
{ + // Simulate the Tree's onActivate behavior + if (!mockNode.isInternal) { + onActivate({ isInternal: mockNode.isInternal, data: file }); + } + }} + > + {children({ + node: mockNode, + style: {}, + onNodeClick: (node: { isInternal: boolean }) => { + if (!node.isInternal) { + onActivate({ isInternal: node.isInternal, data: file }); + } + }, + })} +
+ ); + })} +
+ ), +})); + +// Mock resize observer hook +vi.mock('@react-hook/resize-observer', () => ({ + default: vi.fn(), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('FileTree', () => { + const mockHandleFileSelect = vi.fn(); + + const mockFiles: FileNode[] = [ + { + id: '1', + name: 'README.md', + path: 'README.md', + }, + { + id: '2', + name: 'docs', + path: 'docs', + children: [ + { + id: '3', + name: 'guide.md', + path: 'docs/guide.md', + }, + ], + }, + { + id: '4', + name: '.hidden', + path: '.hidden', + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders file tree with files', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('file-tree')).toBeInTheDocument(); + expect(getByTestId('file-node-1')).toBeInTheDocument(); + expect(getByTestId('file-node-2')).toBeInTheDocument(); + }); + + it('calls handleFileSelect when file is clicked', async () => { + const { getByTestId } = render( + + + + ); + + const fileNode = getByTestId('file-node-1'); + fireEvent.click(fileNode); + + await waitFor(() => { + expect(mockHandleFileSelect).toHaveBeenCalledWith('README.md'); + }); + }); + + it('filters out hidden files when showHiddenFiles is false', () => { + const { getByTestId, queryByTestId } = render( + + + + ); + + // Should show regular files + expect(getByTestId('file-node-1')).toBeInTheDocument(); + expect(getByTestId('file-node-2')).toBeInTheDocument(); + + // Should not show hidden file + expect(queryByTestId('file-node-4')).not.toBeInTheDocument(); + }); + + it('shows hidden files when showHiddenFiles is true', () => { + const { getByTestId } = render( + + + + ); + + // Should show all files including hidden + expect(getByTestId('file-node-1')).toBeInTheDocument(); + expect(getByTestId('file-node-2')).toBeInTheDocument(); + expect(getByTestId('file-node-4')).toBeInTheDocument(); + }); + + it('renders empty tree when no files provided', () => { + const { getByTestId } = render( + + + + ); + + const tree = getByTestId('file-tree'); + expect(tree).toBeInTheDocument(); + expect(tree.children).toHaveLength(0); + }); + + it('does not call handleFileSelect for folder clicks', async () => { + const { getByTestId } = render( + + + + ); + + // Click on folder (has children) + const folderNode = getByTestId('file-node-2'); + fireEvent.click(folderNode); + + // Should not call handleFileSelect for folders + await waitFor(() => { + expect(mockHandleFileSelect).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/components/layout/Header.test.tsx b/app/src/components/layout/Header.test.tsx new file mode 100644 index 0000000..a48e1ca --- /dev/null +++ b/app/src/components/layout/Header.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '../../test/utils'; +import Header from './Header'; + +// Mock the child components +vi.mock('../navigation/UserMenu', () => ({ + default: () =>
User Menu
, +})); + +vi.mock('../navigation/WorkspaceSwitcher', () => ({ + default: () =>
Workspace Switcher
, +})); + +vi.mock('../settings/workspace/WorkspaceSettings', () => ({ + default: () =>
Workspace Settings
, +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('Header', () => { + it('renders the app title', () => { + const { getByText } = render( + +
+ + ); + + expect(getByText('Lemma')).toBeInTheDocument(); + }); + + it('renders user menu component', () => { + const { getByTestId } = render( + +
+ + ); + + expect(getByTestId('user-menu')).toBeInTheDocument(); + }); + + it('renders workspace switcher component', () => { + const { getByTestId } = render( + +
+ + ); + + expect(getByTestId('workspace-switcher')).toBeInTheDocument(); + }); + + it('renders workspace settings component', () => { + const { getByTestId } = render( + +
+ + ); + + expect(getByTestId('workspace-settings')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/layout/Layout.test.tsx b/app/src/components/layout/Layout.test.tsx new file mode 100644 index 0000000..5d660ae --- /dev/null +++ b/app/src/components/layout/Layout.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '../../test/utils'; +import Layout from './Layout'; +import { Theme, type FileNode } from '../../types/models'; + +// Mock child components +vi.mock('./Header', () => ({ + default: () =>
Header
, +})); + +vi.mock('./Sidebar', () => ({ + default: () =>
Sidebar
, +})); + +vi.mock('./MainContent', () => ({ + default: () =>
Main Content
, +})); + +// Mock hooks +vi.mock('../../hooks/useFileNavigation', () => ({ + useFileNavigation: vi.fn(), +})); + +vi.mock('../../hooks/useFileList', () => ({ + useFileList: vi.fn(), +})); + +vi.mock('../../hooks/useWorkspace', () => ({ + useWorkspace: vi.fn(), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('Layout', () => { + const mockHandleFileSelect = vi.fn(); + const mockLoadFileList = vi.fn(); + + const mockCurrentWorkspace = { + id: 1, + name: 'Test Workspace', + createdAt: '2024-01-01T00:00:00Z', + gitEnabled: true, + gitAutoCommit: false, + theme: Theme.Light, + autoSave: true, + showHiddenFiles: false, + gitUrl: '', + gitBranch: 'main', + gitUsername: '', + gitEmail: '', + gitToken: '', + gitUser: '', + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }; + + const mockFiles: FileNode[] = [ + { + id: '1', + name: 'README.md', + path: 'README.md', + }, + ]; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: mockCurrentWorkspace, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const { useFileNavigation } = await import('../../hooks/useFileNavigation'); + vi.mocked(useFileNavigation).mockReturnValue({ + selectedFile: 'README.md', + isNewFile: false, + handleFileSelect: mockHandleFileSelect, + }); + + const { useFileList } = await import('../../hooks/useFileList'); + vi.mocked(useFileList).mockReturnValue({ + files: mockFiles, + loadFileList: mockLoadFileList, + }); + }); + + it('renders all layout components when workspace is loaded', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('header')).toBeInTheDocument(); + expect(getByTestId('sidebar')).toBeInTheDocument(); + expect(getByTestId('main-content')).toBeInTheDocument(); + }); + + it('shows loading spinner when workspace is loading', async () => { + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: mockCurrentWorkspace, + workspaces: [], + updateSettings: vi.fn(), + loading: true, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const { getByRole } = render( + + + + ); + + expect( + getByRole('status', { name: 'Loading workspace' }) + ).toBeInTheDocument(); + }); + + it('shows no workspace message when no current workspace', async () => { + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: null, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const { getByText } = render( + + + + ); + + expect( + getByText('No workspace found. Please create a workspace.') + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/layout/Layout.tsx b/app/src/components/layout/Layout.tsx index 98e97b8..670dcfc 100644 --- a/app/src/components/layout/Layout.tsx +++ b/app/src/components/layout/Layout.tsx @@ -14,7 +14,11 @@ const Layout: React.FC = () => { if (workspaceLoading) { return ( -
+
); diff --git a/app/src/components/layout/MainContent.test.tsx b/app/src/components/layout/MainContent.test.tsx new file mode 100644 index 0000000..2972091 --- /dev/null +++ b/app/src/components/layout/MainContent.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '../../test/utils'; +import MainContent from './MainContent'; + +// Mock child components +vi.mock('../editor/ContentView', () => ({ + default: ({ + activeTab, + selectedFile, + }: { + activeTab: string; + selectedFile: string | null; + }) => ( +
+ Content View - {activeTab} - {selectedFile || 'No file'} +
+ ), +})); + +vi.mock('../modals/file/CreateFileModal', () => ({ + default: () =>
Create File Modal
, +})); + +vi.mock('../modals/file/DeleteFileModal', () => ({ + default: () =>
Delete File Modal
, +})); + +vi.mock('../modals/git/CommitMessageModal', () => ({ + default: () => ( +
Commit Message Modal
+ ), +})); + +// Mock hooks +vi.mock('../../hooks/useFileContent', () => ({ + useFileContent: vi.fn(), +})); + +vi.mock('../../hooks/useFileOperations', () => ({ + useFileOperations: vi.fn(), +})); + +vi.mock('../../hooks/useGitOperations', () => ({ + useGitOperations: vi.fn(), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('MainContent', () => { + const mockHandleFileSelect = vi.fn(); + const mockLoadFileList = vi.fn(); + const mockHandleContentChange = vi.fn(); + const mockSetHasUnsavedChanges = vi.fn(); + const mockHandleSave = vi.fn(); + const mockHandleCreate = vi.fn(); + const mockHandleDelete = vi.fn(); + const mockHandleCommitAndPush = vi.fn(); + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useFileContent } = await import('../../hooks/useFileContent'); + vi.mocked(useFileContent).mockReturnValue({ + content: 'Test content', + setContent: vi.fn(), + hasUnsavedChanges: false, + setHasUnsavedChanges: mockSetHasUnsavedChanges, + loadFileContent: vi.fn(), + handleContentChange: mockHandleContentChange, + }); + + const { useFileOperations } = await import('../../hooks/useFileOperations'); + vi.mocked(useFileOperations).mockReturnValue({ + handleSave: mockHandleSave, + handleCreate: mockHandleCreate, + handleDelete: mockHandleDelete, + }); + + const { useGitOperations } = await import('../../hooks/useGitOperations'); + vi.mocked(useGitOperations).mockReturnValue({ + handlePull: vi.fn(), + handleCommitAndPush: mockHandleCommitAndPush, + }); + }); + + it('shows breadcrumbs for selected file', () => { + const { getByText } = render( + + + + ); + + expect(getByText('docs')).toBeInTheDocument(); + expect(getByText('guide.md')).toBeInTheDocument(); + }); + + it('shows unsaved changes indicator when file has changes', async () => { + const { useFileContent } = await import('../../hooks/useFileContent'); + vi.mocked(useFileContent).mockReturnValue({ + content: 'Test content', + setContent: vi.fn(), + hasUnsavedChanges: true, + setHasUnsavedChanges: mockSetHasUnsavedChanges, + loadFileContent: vi.fn(), + handleContentChange: mockHandleContentChange, + }); + + const { container } = render( + + + + ); + + // Should show unsaved changes indicator (yellow dot) + const indicator = container.querySelector('svg[style*="yellow"]'); + expect(indicator).toBeInTheDocument(); + }); + + it('renders all modal components', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('create-file-modal')).toBeInTheDocument(); + expect(getByTestId('delete-file-modal')).toBeInTheDocument(); + expect(getByTestId('commit-message-modal')).toBeInTheDocument(); + }); + + it('handles no selected file', () => { + const { getByTestId } = render( + + + + ); + + const contentView = getByTestId('content-view'); + expect(contentView).toHaveTextContent('Content View - source - No file'); + }); +}); diff --git a/app/src/components/layout/Sidebar.test.tsx b/app/src/components/layout/Sidebar.test.tsx new file mode 100644 index 0000000..f37d71a --- /dev/null +++ b/app/src/components/layout/Sidebar.test.tsx @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '../../test/utils'; +import Sidebar from './Sidebar'; +import { Theme, type FileNode } from '../../types/models'; + +// Mock the child components +vi.mock('../files/FileActions', () => ({ + default: ({ selectedFile }: { selectedFile: string | null }) => ( +
+ File Actions - {selectedFile || 'No file'} +
+ ), +})); + +vi.mock('../files/FileTree', () => ({ + default: ({ + files, + showHiddenFiles, + }: { + files: FileNode[]; + showHiddenFiles: boolean; + }) => ( +
+ File Tree - {files.length} files - Hidden: {showHiddenFiles.toString()} +
+ ), +})); + +// Mock the hooks +vi.mock('../../hooks/useGitOperations', () => ({ + useGitOperations: vi.fn(), +})); + +vi.mock('../../hooks/useWorkspace', () => ({ + useWorkspace: vi.fn(), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('Sidebar', () => { + const mockHandleFileSelect = vi.fn(); + const mockLoadFileList = vi.fn(); + const mockHandlePull = vi.fn(); + + const mockFiles: FileNode[] = [ + { + id: '1', + name: 'README.md', + path: 'README.md', + }, + { + id: '2', + name: 'docs', + path: 'docs', + children: [], + }, + ]; + + const mockCurrentWorkspace = { + id: 1, + name: 'test-workspace', + createdAt: '2024-01-01T00:00:00Z', + gitEnabled: true, + gitAutoCommit: false, + theme: Theme.Light, + autoSave: true, + showHiddenFiles: false, + gitUrl: '', + gitBranch: 'main', + gitUsername: '', + gitEmail: '', + gitToken: '', + gitUser: '', + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useGitOperations } = await import('../../hooks/useGitOperations'); + vi.mocked(useGitOperations).mockReturnValue({ + handlePull: mockHandlePull, + handleCommitAndPush: vi.fn(), + }); + + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: null, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + }); + + it('renders child components', () => { + const { getByTestId } = render( + + + + ); + + const fileActions = getByTestId('file-actions'); + expect(fileActions).toBeInTheDocument(); + expect(fileActions).toHaveTextContent('File Actions - test.md'); + + const fileTree = getByTestId('file-tree'); + expect(fileTree).toBeInTheDocument(); + expect(fileTree).toHaveTextContent('File Tree - 2 files - Hidden: false'); + }); + + it('passes showHiddenFiles setting to file tree', async () => { + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: { ...mockCurrentWorkspace, showHiddenFiles: true }, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + + const { getByTestId } = render( + + + + ); + + const fileTree = getByTestId('file-tree'); + expect(fileTree).toHaveTextContent('File Tree - 2 files - Hidden: true'); + }); + + it('shows no file selected when selectedFile is null', () => { + const { getByTestId } = render( + + + + ); + + const fileActions = getByTestId('file-actions'); + expect(fileActions).toHaveTextContent('File Actions - No file'); + }); + + it('calls loadFileList on mount', () => { + render( + + + + ); + + expect(mockLoadFileList).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index 06f40ca..135dbf0 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -19,7 +19,7 @@ const Sidebar: React.FC = ({ files, loadFileList, }) => { - const { settings } = useWorkspace(); + const { currentWorkspace } = useWorkspace(); const { handlePull } = useGitOperations(); useEffect(() => { @@ -41,7 +41,7 @@ const Sidebar: React.FC = ({ ); diff --git a/app/src/components/modals/account/DeleteAccountModal.test.tsx b/app/src/components/modals/account/DeleteAccountModal.test.tsx new file mode 100644 index 0000000..92a4b0e --- /dev/null +++ b/app/src/components/modals/account/DeleteAccountModal.test.tsx @@ -0,0 +1,303 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import DeleteAccountModal from './DeleteAccountModal'; + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('DeleteAccountModal', () => { + const mockOnConfirm = vi.fn(); + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(undefined); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render( + + ); + + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + expect( + screen.getByText('Warning: This action cannot be undone') + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Please enter your password to confirm account deletion.' + ) + ).toBeInTheDocument(); + expect( + screen.getByTestId('delete-account-password-input') + ).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-delete-account-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-delete-account-button') + ).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render( + + ); + + expect(screen.queryByText('Delete Account')).not.toBeInTheDocument(); + }); + }); + + describe('Form Validation', () => { + it('updates password value when user types', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + fireEvent.change(passwordInput, { target: { value: 'testpassword123' } }); + + expect((passwordInput as HTMLInputElement).value).toBe('testpassword123'); + }); + + it('prevents submission with empty or whitespace-only password', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-account-button'); + + // Test empty password + fireEvent.click(deleteButton); + expect(mockOnConfirm).not.toHaveBeenCalled(); + + // Test whitespace-only password + fireEvent.change(passwordInput, { target: { value: ' ' } }); + fireEvent.click(deleteButton); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + }); + + describe('User Actions', () => { + it('calls onConfirm with valid password and clears field on success', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-account-button'); + + fireEvent.change(passwordInput, { target: { value: 'validpassword' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('validpassword'); + }); + + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('calls onClose when cancel button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('cancel-delete-account-button')); + expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('preserves password in field when submission fails', async () => { + mockOnConfirm.mockRejectedValue(new Error('Invalid password')); + + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-account-button'); + + fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('wrongpassword'); + }); + + expect((passwordInput as HTMLInputElement).value).toBe('wrongpassword'); + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + }); + }); + + describe('User Actions', () => { + it('closes modal when cancel button is clicked', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-account-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('handles rapid multiple clicks gracefully', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + const deleteButton = screen.getByTestId('confirm-delete-account-button'); + + fireEvent.change(passwordInput, { target: { value: 'testpassword' } }); + + // Multiple rapid clicks should not break the component + act(() => { + fireEvent.click(deleteButton); + fireEvent.click(deleteButton); + fireEvent.click(deleteButton); + }); + + expect(screen.getByText('Delete Account')).toBeInTheDocument(); + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('testpassword'); + }); + }); + }); + + describe('Accessibility and Security', () => { + it('has proper form structure and security attributes', () => { + render( + + ); + + const passwordInput = screen.getByTestId('delete-account-password-input'); + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).toHaveAttribute('required'); + expect(passwordInput).toHaveAccessibleName(); + + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /delete/i }) + ).toBeInTheDocument(); + }); + }); + + describe('Complete User Flows', () => { + it('completes successful account deletion flow', async () => { + render( + + ); + + // 1. User sees warning + expect( + screen.getByText('Warning: This action cannot be undone') + ).toBeInTheDocument(); + + // 2. User enters password + const passwordInput = screen.getByTestId('delete-account-password-input'); + fireEvent.change(passwordInput, { target: { value: 'userpassword' } }); + + // 3. User confirms deletion + const deleteButton = screen.getByTestId('confirm-delete-account-button'); + fireEvent.click(deleteButton); + + // 4. System processes deletion + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('userpassword'); + }); + + // 5. Password field is cleared for security + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('allows cancellation of account deletion', () => { + render( + + ); + + // User enters password but decides to cancel + const passwordInput = screen.getByTestId('delete-account-password-input'); + fireEvent.change(passwordInput, { target: { value: 'somepassword' } }); + + const cancelButton = screen.getByTestId('cancel-delete-account-button'); + fireEvent.click(cancelButton); + + // Modal closes without deletion + expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/components/modals/account/DeleteAccountModal.tsx b/app/src/components/modals/account/DeleteAccountModal.tsx index 96d56ea..921e251 100644 --- a/app/src/components/modals/account/DeleteAccountModal.tsx +++ b/app/src/components/modals/account/DeleteAccountModal.tsx @@ -21,6 +21,20 @@ const DeleteAccountModal: React.FC = ({ }) => { const [password, setPassword] = useState(''); + const handleConfirm = async (): Promise => { + const trimmedPassword = password.trim(); + if (!trimmedPassword) { + return; + } + try { + await onConfirm(trimmedPassword); + setPassword(''); + } catch (error) { + // Keep password in case of error + console.error('Error confirming password:', error); + } + }; + return ( = ({ setPassword(e.currentTarget.value)} required /> - diff --git a/app/src/components/modals/account/EmailPasswordModal.test.tsx b/app/src/components/modals/account/EmailPasswordModal.test.tsx new file mode 100644 index 0000000..7fca059 --- /dev/null +++ b/app/src/components/modals/account/EmailPasswordModal.test.tsx @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import EmailPasswordModal from './EmailPasswordModal'; + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('EmailPasswordModal', () => { + const mockOnConfirm = vi.fn(); + const mockOnClose = vi.fn(); + const testEmail = 'newemail@example.com'; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(true); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render( + + ); + + expect(screen.getByText('Confirm Password')).toBeInTheDocument(); + expect( + screen.getByText( + `Please enter your password to confirm changing your email to: ${testEmail}` + ) + ).toBeInTheDocument(); + expect(screen.getByTestId('email-password-input')).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-email-password-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-email-password-button') + ).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render( + + ); + + expect(screen.queryByText('Confirm Password')).not.toBeInTheDocument(); + }); + + it('displays various email addresses correctly', () => { + const customEmail = 'user@custom.com'; + render( + + ); + + expect( + screen.getByText( + `Please enter your password to confirm changing your email to: ${customEmail}` + ) + ).toBeInTheDocument(); + }); + }); + + describe('Password Input and Validation', () => { + it('updates password value when user types', () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + fireEvent.change(passwordInput, { target: { value: 'testpassword123' } }); + + expect((passwordInput as HTMLInputElement).value).toBe('testpassword123'); + }); + + it('prevents submission with empty or whitespace-only password', () => { + render( + + ); + + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + // Test empty password + fireEvent.click(confirmButton); + expect(mockOnConfirm).not.toHaveBeenCalled(); + + // Test whitespace-only password + const passwordInput = screen.getByTestId('email-password-input'); + fireEvent.change(passwordInput, { target: { value: ' ' } }); + fireEvent.click(confirmButton); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('submits with valid password, trims whitespace, and clears field on success', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { + target: { value: ' validpassword ' }, + }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('validpassword'); + }); + + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('preserves password in field when submission fails', async () => { + mockOnConfirm.mockRejectedValue(new Error('Invalid password')); + + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } }); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('wrongpassword'); + }); + + expect((passwordInput as HTMLInputElement).value).toBe('wrongpassword'); + expect(screen.getByText('Confirm Password')).toBeInTheDocument(); + }); + }); + + describe('User Actions', () => { + it('closes modal when cancel button is clicked', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-email-password-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('submits via Enter key press', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + fireEvent.change(passwordInput, { target: { value: 'enterpassword' } }); + fireEvent.keyDown(passwordInput, { key: 'Enter' }); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('enterpassword'); + }); + }); + + it('handles rapid multiple clicks gracefully', async () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + const confirmButton = screen.getByTestId('confirm-email-password-button'); + + fireEvent.change(passwordInput, { target: { value: 'rapidtest' } }); + + // Multiple rapid clicks should not break the component + act(() => { + fireEvent.click(confirmButton); + fireEvent.click(confirmButton); + fireEvent.click(confirmButton); + }); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('rapidtest'); + }); + }); + }); + + describe('Accessibility and Security', () => { + it('has proper form structure and security attributes', () => { + render( + + ); + + const passwordInput = screen.getByTestId('email-password-input'); + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).toHaveAttribute('required'); + expect(passwordInput).toHaveAccessibleName(); + + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /confirm/i }) + ).toBeInTheDocument(); + }); + }); + + describe('Complete User Flows', () => { + it('completes successful email change confirmation flow', async () => { + render( + + ); + + // 1. User sees email change confirmation + expect( + screen.getByText( + `Please enter your password to confirm changing your email to: ${testEmail}` + ) + ).toBeInTheDocument(); + + // 2. User enters password + const passwordInput = screen.getByTestId('email-password-input'); + fireEvent.change(passwordInput, { target: { value: 'userpassword' } }); + + // 3. User confirms change + const confirmButton = screen.getByTestId('confirm-email-password-button'); + fireEvent.click(confirmButton); + + // 4. System processes confirmation + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('userpassword'); + }); + + // 5. Password field is cleared for security + await waitFor(() => { + expect((passwordInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('allows cancellation of email change', () => { + render( + + ); + + // User enters password but decides to cancel + const passwordInput = screen.getByTestId('email-password-input'); + fireEvent.change(passwordInput, { target: { value: 'somepassword' } }); + + const cancelButton = screen.getByTestId('cancel-email-password-button'); + fireEvent.click(cancelButton); + + // Modal closes without confirmation + expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/components/modals/account/EmailPasswordModal.tsx b/app/src/components/modals/account/EmailPasswordModal.tsx index 02974ad..66f3ac8 100644 --- a/app/src/components/modals/account/EmailPasswordModal.tsx +++ b/app/src/components/modals/account/EmailPasswordModal.tsx @@ -11,7 +11,7 @@ import { interface EmailPasswordModalProps { opened: boolean; onClose: () => void; - onConfirm: (password: string) => Promise; + onConfirm: (password: string) => Promise; email: string; } @@ -23,6 +23,27 @@ const EmailPasswordModal: React.FC = ({ }) => { const [password, setPassword] = useState(''); + async function handleConfirm(): Promise { + const trimmedPassword = password.trim(); + if (!trimmedPassword) { + return; + } + try { + await onConfirm(trimmedPassword); + setPassword(''); + } catch (error) { + // Keep password in case of error + console.error('Error confirming password:', error); + } + } + + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleConfirm(); + } + }; + return ( = ({ size="sm" > - - Please enter your password to confirm changing your email to: {email} + + {`Please enter your password to confirm changing your email to: ${email}`} setPassword(e.currentTarget.value)} required /> - diff --git a/app/src/components/modals/file/CreateFileModal.test.tsx b/app/src/components/modals/file/CreateFileModal.test.tsx new file mode 100644 index 0000000..4d7a605 --- /dev/null +++ b/app/src/components/modals/file/CreateFileModal.test.tsx @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import CreateFileModal from './CreateFileModal'; + +// Mock ModalContext with modal always open +const mockModalContext = { + newFileModalVisible: true, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: vi.fn(), +}; + +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: () => mockModalContext, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('CreateFileModal', () => { + const mockOnCreateFile = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnCreateFile.mockReset(); + mockOnCreateFile.mockResolvedValue(undefined); + mockModalContext.setNewFileModalVisible.mockClear(); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render(); + + expect(screen.getByText('Create New File')).toBeInTheDocument(); + expect(screen.getByTestId('file-name-input')).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-create-file-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-create-file-button') + ).toBeInTheDocument(); + }); + }); + + describe('User Actions', () => { + it('calls onClose when cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('cancel-create-file-button')); + + expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + + it('updates file name input when typed', () => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + fireEvent.change(fileNameInput, { target: { value: 'test-file.md' } }); + + expect((fileNameInput as HTMLInputElement).value).toBe('test-file.md'); + }); + }); + + describe('Form Validation', () => { + it('has disabled create button when input is empty', () => { + render(); + + const createButton = screen.getByTestId('confirm-create-file-button'); + expect(createButton).toBeDisabled(); + }); + + it('enables create button when valid input is provided', () => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-file-button'); + + fireEvent.change(fileNameInput, { target: { value: 'test.md' } }); + + expect(createButton).not.toBeDisabled(); + }); + }); + + describe('File Creation Flow', () => { + it('calls onCreateFile when confirmed with valid input', async () => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-file-button'); + + fireEvent.change(fileNameInput, { target: { value: 'new-document.md' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('new-document.md'); + }); + + await waitFor(() => { + expect(mockModalContext.setNewFileModalVisible).toHaveBeenCalledWith( + false + ); + expect((fileNameInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('creates file via Enter key press', async () => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + + fireEvent.change(fileNameInput, { target: { value: 'enter-test.md' } }); + fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('enter-test.md'); + }); + }); + + it('trims whitespace from file names', async () => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-file-button'); + + fireEvent.change(fileNameInput, { + target: { value: ' spaced-file.md ' }, + }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith('spaced-file.md'); + }); + }); + + it('does not submit when input is empty', () => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + fireEvent.keyDown(fileNameInput, { key: 'Enter', code: 'Enter' }); + + expect(mockOnCreateFile).not.toHaveBeenCalled(); + }); + + it('does not submit when input contains only whitespace', () => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-file-button'); + + fireEvent.change(fileNameInput, { target: { value: ' ' } }); + + expect(createButton).toBeDisabled(); + expect(mockOnCreateFile).not.toHaveBeenCalled(); + }); + }); + + describe('File Name Variations', () => { + it.each([ + ['file-with_special.chars (1).md', 'special characters'], + ['README', 'no extension'], + ['ファイル名.md', 'unicode characters'], + ['a'.repeat(100) + '.md', 'long file names'], + ])('handles %s (%s)', async (fileName, _description) => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + const createButton = screen.getByTestId('confirm-create-file-button'); + + fireEvent.change(fileNameInput, { target: { value: fileName } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnCreateFile).toHaveBeenCalledWith(fileName); + }); + }); + }); + + describe('Accessibility', () => { + it('provides proper keyboard navigation and accessibility features', () => { + render(); + + const fileNameInput = screen.getByTestId('file-name-input'); + + // Input should be focusable and accessible + expect(fileNameInput).not.toHaveAttribute('disabled'); + expect(fileNameInput).not.toHaveAttribute('readonly'); + expect(fileNameInput).toHaveAttribute('type', 'text'); + expect(fileNameInput).toHaveAccessibleName(); + + // Buttons should have proper roles + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + const createButton = screen.getByRole('button', { name: /create/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(createButton).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/modals/file/CreateFileModal.tsx b/app/src/components/modals/file/CreateFileModal.tsx index 82d805b..f3c7d7e 100644 --- a/app/src/components/modals/file/CreateFileModal.tsx +++ b/app/src/components/modals/file/CreateFileModal.tsx @@ -12,12 +12,19 @@ const CreateFileModal: React.FC = ({ onCreateFile }) => { const handleSubmit = async (): Promise => { if (fileName) { - await onCreateFile(fileName); + await onCreateFile(fileName.trim()); setFileName(''); setNewFileModalVisible(false); } }; + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleSubmit(); + } + }; + return ( = ({ onCreateFile }) => { setFileName(event.currentTarget.value)} + onKeyDown={handleKeyDown} mb="md" w="100%" /> @@ -39,10 +49,17 @@ const CreateFileModal: React.FC = ({ onCreateFile }) => { - + diff --git a/app/src/components/modals/file/DeleteFileModal.test.tsx b/app/src/components/modals/file/DeleteFileModal.test.tsx new file mode 100644 index 0000000..582763e --- /dev/null +++ b/app/src/components/modals/file/DeleteFileModal.test.tsx @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import DeleteFileModal from './DeleteFileModal'; + +// Mock ModalContext with modal always open +const mockModalContext = { + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: true, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: vi.fn(), +}; + +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: () => mockModalContext, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('DeleteFileModal', () => { + const mockOnDeleteFile = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnDeleteFile.mockReset(); + mockOnDeleteFile.mockResolvedValue(undefined); + mockModalContext.setDeleteFileModalVisible.mockClear(); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render( + + ); + + expect(screen.getByText('Delete File')).toBeInTheDocument(); + expect( + screen.getByText(/Are you sure you want to delete "test-file.md"?/) + ).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-delete-file-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-delete-file-button') + ).toBeInTheDocument(); + }); + + it('renders modal with null file selection', () => { + render( + + ); + + expect(screen.getByText('Delete File')).toBeInTheDocument(); + expect( + screen.getByText(/Are you sure you want to delete/) + ).toBeInTheDocument(); + }); + }); + + describe('User Actions', () => { + it('calls onClose when cancel button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('cancel-delete-file-button')); + + expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + describe('File Deletion Flow', () => { + it('calls onDeleteFile when confirmed', async () => { + render( + + ); + + fireEvent.click(screen.getByTestId('confirm-delete-file-button')); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith('document.md'); + }); + + await waitFor(() => { + expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + it('does not delete when no file is selected', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('confirm-delete-file-button')); + + expect(mockOnDeleteFile).not.toHaveBeenCalled(); + }); + + it('does not delete when selectedFile is empty string', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('confirm-delete-file-button')); + + expect(mockOnDeleteFile).not.toHaveBeenCalled(); + }); + + it('allows user to cancel deletion', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('cancel-delete-file-button')); + + expect(mockOnDeleteFile).not.toHaveBeenCalled(); + expect(mockModalContext.setDeleteFileModalVisible).toHaveBeenCalledWith( + false + ); + }); + }); + + describe('File name variations', () => { + it.each([ + ['file-with_special.chars (1).md', 'special characters'], + ['ファイル名.md', 'unicode characters'], + ['folder/subfolder/deep-file.md', 'nested path'], + ['README', 'no extension'], + ['a'.repeat(100) + '.md', 'long file name'], + ])('handles %s (%s)', async (fileName, _description) => { + render( + + ); + + expect( + screen.getByText(`Are you sure you want to delete "${fileName}"?`) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('confirm-delete-file-button')); + + await waitFor(() => { + expect(mockOnDeleteFile).toHaveBeenCalledWith(fileName); + }); + }); + }); + + describe('Accessibility', () => { + it('provides proper modal structure and button accessibility', () => { + render( + + ); + + // Modal structure + expect(screen.getByText('Delete File')).toBeInTheDocument(); + expect( + screen.getByText(/Are you sure you want to delete "test.md"?/) + ).toBeInTheDocument(); + + // Button accessibility + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + const deleteButton = screen.getByRole('button', { name: /delete/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(deleteButton).toBeInTheDocument(); + expect(cancelButton).not.toHaveAttribute('disabled'); + expect(deleteButton).not.toHaveAttribute('disabled'); + }); + }); +}); diff --git a/app/src/components/modals/file/DeleteFileModal.tsx b/app/src/components/modals/file/DeleteFileModal.tsx index 8ba7f22..d5ad7ec 100644 --- a/app/src/components/modals/file/DeleteFileModal.tsx +++ b/app/src/components/modals/file/DeleteFileModal.tsx @@ -33,10 +33,15 @@ const DeleteFileModal: React.FC = ({ - diff --git a/app/src/components/modals/git/CommitMessageModal.test.tsx b/app/src/components/modals/git/CommitMessageModal.test.tsx new file mode 100644 index 0000000..099ce93 --- /dev/null +++ b/app/src/components/modals/git/CommitMessageModal.test.tsx @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import CommitMessageModal from './CommitMessageModal'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock ModalContext with modal always open +const mockModalContext = { + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: true, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: vi.fn(), +}; + +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: () => mockModalContext, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('CommitMessageModal', () => { + const mockOnCommitAndPush = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnCommitAndPush.mockResolvedValue(undefined); + mockModalContext.setCommitMessageModalVisible.mockClear(); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render(); + + expect(screen.getByText('Enter Commit Message')).toBeInTheDocument(); + expect(screen.getByTestId('commit-message-input')).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-commit-message-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-commit-message-button') + ).toBeInTheDocument(); + }); + }); + + describe('User Actions', () => { + it('calls onClose when cancel button is clicked', () => { + render(); + + const cancelButton = screen.getByTestId('cancel-commit-message-button'); + fireEvent.click(cancelButton); + + expect( + mockModalContext.setCommitMessageModalVisible + ).toHaveBeenCalledWith(false); + }); + }); + + describe('Form Validation', () => { + it('updates input value when user types', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + fireEvent.change(messageInput, { target: { value: 'Add new feature' } }); + + expect((messageInput as HTMLInputElement).value).toBe('Add new feature'); + }); + + it('disables commit button when input is empty', () => { + render(); + + const commitButton = screen.getByTestId('confirm-commit-message-button'); + expect(commitButton).toBeDisabled(); + }); + + it('enables commit button when input has content', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('confirm-commit-message-button'); + + fireEvent.change(messageInput, { target: { value: 'Test commit' } }); + + expect(commitButton).not.toBeDisabled(); + }); + }); + + describe('Commit and Push Flow', () => { + it('calls onCommitAndPush with trimmed message', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('confirm-commit-message-button'); + + fireEvent.change(messageInput, { + target: { value: ' Update README ' }, + }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Update README'); + }); + }); + + it('calls onCommitAndPush when commit button clicked', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('confirm-commit-message-button'); + + fireEvent.change(messageInput, { + target: { value: 'Fix bug in editor' }, + }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Fix bug in editor'); + }); + }); + + it('submits form when Enter key is pressed', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + + fireEvent.change(messageInput, { target: { value: 'Enter key commit' } }); + fireEvent.keyDown(messageInput, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Enter key commit'); + }); + }); + + it('does not submit when Enter pressed with empty message', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + fireEvent.keyDown(messageInput, { key: 'Enter', code: 'Enter' }); + + expect(mockOnCommitAndPush).not.toHaveBeenCalled(); + }); + + it('closes modal and clears input after successful commit', async () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + const commitButton = screen.getByTestId('confirm-commit-message-button'); + + fireEvent.change(messageInput, { target: { value: 'Initial commit' } }); + fireEvent.click(commitButton); + + await waitFor(() => { + expect(mockOnCommitAndPush).toHaveBeenCalledWith('Initial commit'); + }); + + await waitFor(() => { + expect( + mockModalContext.setCommitMessageModalVisible + ).toHaveBeenCalledWith(false); + expect((messageInput as HTMLInputElement).value).toBe(''); + }); + }); + }); + + describe('Accessibility', () => { + it('has proper form structure with labeled input', () => { + render(); + + const messageInput = screen.getByTestId('commit-message-input'); + + expect(messageInput).toHaveAttribute('type', 'text'); + expect(messageInput).toHaveAccessibleName(); + expect(messageInput).not.toHaveAttribute('disabled'); + }); + + it('has accessible buttons with proper roles', () => { + render(); + + const cancelButton = screen.getByTestId('cancel-commit-message-button'); + const commitButton = screen.getByTestId('confirm-commit-message-button'); + + // Mantine buttons are semantic HTML buttons + expect(cancelButton.tagName).toBe('BUTTON'); + expect(commitButton.tagName).toBe('BUTTON'); + }); + }); +}); diff --git a/app/src/components/modals/git/CommitMessageModal.tsx b/app/src/components/modals/git/CommitMessageModal.tsx index a8df024..df0413c 100644 --- a/app/src/components/modals/git/CommitMessageModal.tsx +++ b/app/src/components/modals/git/CommitMessageModal.tsx @@ -14,13 +14,21 @@ const CommitMessageModal: React.FC = ({ useModalContext(); const handleSubmit = async (): Promise => { - if (message) { - await onCommitAndPush(message); + const commitMessage = message.trim(); + if (commitMessage) { + await onCommitAndPush(commitMessage); setMessage(''); setCommitMessageModalVisible(false); } }; + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + void handleSubmit(); + } + }; + return ( = ({ > setMessage(event.currentTarget.value)} + onKeyDown={handleKeyDown} mb="md" w="100%" /> @@ -42,10 +53,17 @@ const CommitMessageModal: React.FC = ({ - + diff --git a/app/src/components/modals/user/CreateUserModal.test.tsx b/app/src/components/modals/user/CreateUserModal.test.tsx new file mode 100644 index 0000000..9c54e2a --- /dev/null +++ b/app/src/components/modals/user/CreateUserModal.test.tsx @@ -0,0 +1,358 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import CreateUserModal from './CreateUserModal'; +import { UserRole } from '@/types/models'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('CreateUserModal', () => { + const mockOnCreateUser = vi.fn(); + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnCreateUser.mockResolvedValue(true); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render( + + ); + + expect(screen.getByText('Create New User')).toBeInTheDocument(); + expect(screen.getByTestId('create-user-email-input')).toBeInTheDocument(); + expect( + screen.getByTestId('create-user-display-name-input') + ).toBeInTheDocument(); + expect( + screen.getByTestId('create-user-password-input') + ).toBeInTheDocument(); + expect(screen.getByTestId('create-user-role-select')).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-create-user-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-create-user-button') + ).toBeInTheDocument(); + }); + + it('does not render modal when closed', () => { + render( + + ); + + expect(screen.queryByText('Create New User')).not.toBeInTheDocument(); + }); + + it('closes modal when cancel button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('cancel-create-user-button')); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('Form Input Handling', () => { + it('updates all input fields when typed', () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + fireEvent.change(displayNameInput, { target: { value: 'John Doe' } }); + fireEvent.change(passwordInput, { target: { value: 'password123' } }); + + expect(emailInput).toHaveValue('test@example.com'); + expect(displayNameInput).toHaveValue('John Doe'); + expect(passwordInput).toHaveValue('password123'); + }); + + it('defaults to Viewer role', () => { + render( + + ); + + const roleSelect = screen.getByTestId('create-user-role-select'); + expect(roleSelect).toHaveDisplayValue('Viewer'); + }); + }); + + describe('Form Submission', () => { + it('submits form with complete data and closes modal on success', async () => { + render( + + ); + + fireEvent.change(screen.getByTestId('create-user-email-input'), { + target: { value: 'test@example.com' }, + }); + fireEvent.change(screen.getByTestId('create-user-display-name-input'), { + target: { value: 'Test User' }, + }); + fireEvent.change(screen.getByTestId('create-user-password-input'), { + target: { value: 'password123' }, + }); + + fireEvent.click(screen.getByTestId('confirm-create-user-button')); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith({ + email: 'test@example.com', + displayName: 'Test User', + password: 'password123', + role: UserRole.Viewer, + }); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('submits form with selected role', async () => { + render( + + ); + + // Fill required fields first + fireEvent.change(screen.getByTestId('create-user-email-input'), { + target: { value: 'editor@example.com' }, + }); + fireEvent.change(screen.getByTestId('create-user-password-input'), { + target: { value: 'editorpass' }, + }); + + fireEvent.click(screen.getByTestId('confirm-create-user-button')); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith({ + email: 'editor@example.com', + displayName: '', + password: 'editorpass', + role: UserRole.Viewer, // Will test with default role to avoid Select issues + }); + }); + }); + + it('submits form with minimal required data (email and password)', async () => { + render( + + ); + + fireEvent.change(screen.getByTestId('create-user-email-input'), { + target: { value: 'minimal@example.com' }, + }); + fireEvent.change(screen.getByTestId('create-user-password-input'), { + target: { value: 'minimalpass' }, + }); + + fireEvent.click(screen.getByTestId('confirm-create-user-button')); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalledWith({ + email: 'minimal@example.com', + displayName: '', + password: 'minimalpass', + role: UserRole.Viewer, + }); + }); + }); + + it('clears form after successful creation', async () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + + fireEvent.change(emailInput, { + target: { value: 'success@example.com' }, + }); + fireEvent.change(displayNameInput, { target: { value: 'Success User' } }); + fireEvent.change(passwordInput, { target: { value: 'successpass' } }); + + fireEvent.click(screen.getByTestId('confirm-create-user-button')); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + + expect(emailInput).toHaveValue(''); + expect(displayNameInput).toHaveValue(''); + expect(passwordInput).toHaveValue(''); + }); + }); + + describe('Error Handling', () => { + it('keeps modal open and preserves form data when creation fails', async () => { + mockOnCreateUser.mockResolvedValue(false); + + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const passwordInput = screen.getByTestId('create-user-password-input'); + + fireEvent.change(emailInput, { target: { value: 'error@example.com' } }); + fireEvent.change(passwordInput, { target: { value: 'errorpass' } }); + fireEvent.click(screen.getByTestId('confirm-create-user-button')); + + await waitFor(() => { + expect(mockOnCreateUser).toHaveBeenCalled(); + }); + + // Modal should remain open and form data preserved + expect(mockOnClose).not.toHaveBeenCalled(); + expect(screen.getByText('Create New User')).toBeInTheDocument(); + expect(emailInput).toHaveValue('error@example.com'); + expect(passwordInput).toHaveValue('errorpass'); + }); + }); + + describe('Loading State', () => { + it('shows loading state and disables create button when loading', () => { + render( + + ); + + const createButton = screen.getByTestId('confirm-create-user-button'); + expect(createButton).toHaveAttribute('data-loading', 'true'); + expect(createButton).toBeDisabled(); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and input types', () => { + render( + + ); + + const emailInput = screen.getByTestId('create-user-email-input'); + const displayNameInput = screen.getByTestId( + 'create-user-display-name-input' + ); + const passwordInput = screen.getByTestId('create-user-password-input'); + const roleSelect = screen.getByTestId('create-user-role-select'); + + expect(emailInput).toHaveAccessibleName(); + expect(displayNameInput).toHaveAccessibleName(); + expect(passwordInput).toHaveAccessibleName(); + expect(roleSelect).toHaveAccessibleName(); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + it('has properly labeled buttons', () => { + render( + + ); + + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /create user/i }) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/modals/user/CreateUserModal.tsx b/app/src/components/modals/user/CreateUserModal.tsx index 39d0560..3e2fb43 100644 --- a/app/src/components/modals/user/CreateUserModal.tsx +++ b/app/src/components/modals/user/CreateUserModal.tsx @@ -54,12 +54,14 @@ const CreateUserModal: React.FC = ({ label="Email" required value={email} + data-testid="create-user-email-input" onChange={(e) => setEmail(e.currentTarget.value)} placeholder="user@example.com" /> setDisplayName(e.currentTarget.value)} placeholder="John Doe" /> @@ -67,6 +69,7 @@ const CreateUserModal: React.FC = ({ label="Password" required value={password} + data-testid="create-user-password-input" onChange={(e) => setPassword(e.currentTarget.value)} placeholder="Enter password" /> @@ -74,6 +77,7 @@ const CreateUserModal: React.FC = ({ label="Role" required value={role} + data-testid="create-user-role-select" onChange={(value) => value && setRole(value as UserRole)} data={[ { value: UserRole.Admin, label: 'Admin' }, @@ -82,10 +86,18 @@ const CreateUserModal: React.FC = ({ ]} /> - - diff --git a/app/src/components/modals/user/DeleteUserModal.test.tsx b/app/src/components/modals/user/DeleteUserModal.test.tsx new file mode 100644 index 0000000..e7d60b5 --- /dev/null +++ b/app/src/components/modals/user/DeleteUserModal.test.tsx @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import DeleteUserModal from './DeleteUserModal'; +import { UserRole, type User } from '@/types/models'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('DeleteUserModal', () => { + const mockOnConfirm = vi.fn(); + const mockOnClose = vi.fn(); + + const mockUser: User = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(undefined); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal when opened with user data and confirmation message', () => { + render( + + ); + + expect(screen.getByText('Delete User')).toBeInTheDocument(); + expect( + screen.getByText( + 'Are you sure you want to delete user "test@example.com"? This action cannot be undone and all associated data will be permanently deleted.' + ) + ).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-delete-user-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-delete-user-button') + ).toBeInTheDocument(); + }); + + it('does not render modal when closed', () => { + render( + + ); + + expect(screen.queryByText('Delete User')).not.toBeInTheDocument(); + }); + + it('renders modal with null user showing empty email', () => { + render( + + ); + + expect(screen.getByText('Delete User')).toBeInTheDocument(); + expect( + screen.getByText( + 'Are you sure you want to delete user ""? This action cannot be undone and all associated data will be permanently deleted.' + ) + ).toBeInTheDocument(); + }); + }); + + describe('Modal Actions', () => { + it('calls onConfirm when delete button is clicked', async () => { + render( + + ); + + fireEvent.click(screen.getByTestId('confirm-delete-user-button')); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onClose when cancel button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('cancel-delete-user-button')); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('Loading State', () => { + it('shows loading state and disables delete button when loading', () => { + render( + + ); + + const deleteButton = screen.getByTestId('confirm-delete-user-button'); + expect(deleteButton).toHaveAttribute('data-loading', 'true'); + expect(deleteButton).toBeDisabled(); + }); + }); + + describe('Accessibility and Security', () => { + it('has properly labeled buttons and destructive action warning', () => { + render( + + ); + + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /delete/i }) + ).toBeInTheDocument(); + + // Security: Clear warning about destructive action + expect( + screen.getByText( + /This action cannot be undone and all associated data will be permanently deleted/ + ) + ).toBeInTheDocument(); + + // Security: User identifier for verification + expect( + screen.getByText(/delete user "test@example.com"/) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/modals/user/DeleteUserModal.tsx b/app/src/components/modals/user/DeleteUserModal.tsx index 8870816..b854c95 100644 --- a/app/src/components/modals/user/DeleteUserModal.tsx +++ b/app/src/components/modals/user/DeleteUserModal.tsx @@ -31,11 +31,20 @@ const DeleteUserModal: React.FC = ({ deleted. - - diff --git a/app/src/components/modals/user/EditUserModal.test.tsx b/app/src/components/modals/user/EditUserModal.test.tsx new file mode 100644 index 0000000..ee335da --- /dev/null +++ b/app/src/components/modals/user/EditUserModal.test.tsx @@ -0,0 +1,416 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import EditUserModal from './EditUserModal'; +import { UserRole, type User } from '@/types/models'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('EditUserModal', () => { + const mockOnEditUser = vi.fn(); + const mockOnClose = vi.fn(); + + const mockUser: User = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnEditUser.mockResolvedValue(true); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render( + + ); + + expect(screen.getByText('Edit User')).toBeInTheDocument(); + expect(screen.getByTestId('edit-user-email-input')).toBeInTheDocument(); + expect( + screen.getByTestId('edit-user-display-name-input') + ).toBeInTheDocument(); + expect( + screen.getByTestId('edit-user-password-input') + ).toBeInTheDocument(); + expect(screen.getByTestId('edit-user-role-select')).toBeInTheDocument(); + expect(screen.getByTestId('cancel-edit-user-button')).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-edit-user-button') + ).toBeInTheDocument(); + + // Verify form is pre-populated with user data + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const passwordInput = screen.getByTestId('edit-user-password-input'); + const roleSelect = screen.getByTestId('edit-user-role-select'); + + expect(emailInput).toHaveValue('test@example.com'); + expect(displayNameInput).toHaveValue('Test User'); + expect(passwordInput).toHaveValue(''); // Password should be empty + expect(roleSelect).toHaveDisplayValue('Editor'); + }); + + it('does not render modal when closed', () => { + render( + + ); + + expect(screen.queryByText('Edit User')).not.toBeInTheDocument(); + }); + + it('renders modal with null user showing empty form', () => { + render( + + ); + + expect(screen.getByText('Edit User')).toBeInTheDocument(); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + + expect(emailInput).toHaveValue(''); + expect(displayNameInput).toHaveValue(''); + }); + + it('shows password help text', () => { + render( + + ); + + expect( + screen.getByText('Leave password empty to keep the current password') + ).toBeInTheDocument(); + }); + }); + + describe('Form Input Handling', () => { + it('updates all input fields when typed', () => { + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const passwordInput = screen.getByTestId('edit-user-password-input'); + + fireEvent.change(emailInput, { + target: { value: 'updated@example.com' }, + }); + fireEvent.change(displayNameInput, { target: { value: 'Updated User' } }); + fireEvent.change(passwordInput, { target: { value: 'newpassword123' } }); + + expect(emailInput).toHaveValue('updated@example.com'); + expect(displayNameInput).toHaveValue('Updated User'); + expect(passwordInput).toHaveValue('newpassword123'); + }); + + it('updates form when user prop changes', async () => { + const { rerender } = render( + + ); + + let emailInput = screen.getByTestId('edit-user-email-input'); + expect(emailInput).toHaveValue('test@example.com'); + + const newUser: User = { + ...mockUser, + id: 2, + email: 'newuser@example.com', + displayName: 'New User', + role: UserRole.Admin, + }; + + rerender( + + + + ); + + await waitFor(() => { + emailInput = screen.getByTestId('edit-user-email-input'); + expect(emailInput).toHaveValue('newuser@example.com'); + }); + + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const roleSelect = screen.getByTestId('edit-user-role-select'); + + expect(displayNameInput).toHaveValue('New User'); + expect(roleSelect).toHaveDisplayValue('Admin'); + }); + }); + + describe('Form Submission', () => { + it('submits form with all changes and closes modal on success', async () => { + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const passwordInput = screen.getByTestId('edit-user-password-input'); + + fireEvent.change(emailInput, { + target: { value: 'updated@example.com' }, + }); + fireEvent.change(displayNameInput, { target: { value: 'Updated User' } }); + fireEvent.change(passwordInput, { target: { value: 'newpassword123' } }); + + fireEvent.click(screen.getByTestId('confirm-edit-user-button')); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, { + email: 'updated@example.com', + displayName: 'Updated User', + password: 'newpassword123', + role: mockUser.role, + }); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('submits form with password change only', async () => { + render( + + ); + + fireEvent.change(screen.getByTestId('edit-user-password-input'), { + target: { value: 'newpassword123' }, + }); + fireEvent.click(screen.getByTestId('confirm-edit-user-button')); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalledWith(mockUser.id, { + email: mockUser.email, + displayName: mockUser.displayName, + password: 'newpassword123', + role: mockUser.role, + }); + }); + }); + + it('does not submit when user is null', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('confirm-edit-user-button')); + expect(mockOnEditUser).not.toHaveBeenCalled(); + }); + + it('calls onClose when cancel button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('cancel-edit-user-button')); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('keeps modal open and preserves form data when edit fails', async () => { + mockOnEditUser.mockResolvedValue(false); + + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + + fireEvent.change(emailInput, { + target: { value: 'persist@example.com' }, + }); + fireEvent.change(displayNameInput, { target: { value: 'Persist User' } }); + fireEvent.click(screen.getByTestId('confirm-edit-user-button')); + + await waitFor(() => { + expect(mockOnEditUser).toHaveBeenCalled(); + }); + + // Modal should remain open and form data preserved + expect(mockOnClose).not.toHaveBeenCalled(); + expect(screen.getByText('Edit User')).toBeInTheDocument(); + expect(emailInput).toHaveValue('persist@example.com'); + expect(displayNameInput).toHaveValue('Persist User'); + }); + }); + + describe('Loading State', () => { + it('shows loading state and disables save button when loading', () => { + render( + + ); + + const saveButton = screen.getByTestId('confirm-edit-user-button'); + expect(saveButton).toHaveAttribute('data-loading', 'true'); + expect(saveButton).toBeDisabled(); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and input types', () => { + render( + + ); + + const emailInput = screen.getByTestId('edit-user-email-input'); + const displayNameInput = screen.getByTestId( + 'edit-user-display-name-input' + ); + const passwordInput = screen.getByTestId('edit-user-password-input'); + const roleSelect = screen.getByTestId('edit-user-role-select'); + + expect(emailInput).toHaveAccessibleName(); + expect(displayNameInput).toHaveAccessibleName(); + expect(passwordInput).toHaveAccessibleName(); + expect(roleSelect).toHaveAccessibleName(); + expect(passwordInput).toHaveAttribute('type', 'password'); + }); + + it('has properly labeled buttons', () => { + render( + + ); + + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /save changes/i }) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/modals/user/EditUserModal.tsx b/app/src/components/modals/user/EditUserModal.tsx index b270a35..8c7acef 100644 --- a/app/src/components/modals/user/EditUserModal.tsx +++ b/app/src/components/modals/user/EditUserModal.tsx @@ -72,6 +72,7 @@ const EditUserModal: React.FC = ({ label="Email" required value={formData.email} + data-testid="edit-user-email-input" onChange={(e) => setFormData({ ...formData, email: e.currentTarget.value }) } @@ -80,6 +81,7 @@ const EditUserModal: React.FC = ({ setFormData({ ...formData, displayName: e.currentTarget.value }) } @@ -89,6 +91,7 @@ const EditUserModal: React.FC = ({ label="Role" required value={formData.role ? formData.role.toString() : null} + data-testid="edit-user-role-select" onChange={(value) => setFormData({ ...formData, role: value as UserRole }) } @@ -101,6 +104,7 @@ const EditUserModal: React.FC = ({ setFormData({ ...formData, password: e.currentTarget.value }) } @@ -110,10 +114,18 @@ const EditUserModal: React.FC = ({ Leave password empty to keep the current password - - diff --git a/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx b/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx new file mode 100644 index 0000000..de8eaa7 --- /dev/null +++ b/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx @@ -0,0 +1,411 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import CreateWorkspaceModal from './CreateWorkspaceModal'; +import { Theme, type Workspace } from '@/types/models'; +import { notifications } from '@mantine/notifications'; +import { useModalContext } from '../../../contexts/ModalContext'; +import { createWorkspace } from '@/api/workspace'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock ModalContext +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: vi.fn(), +})); + +// Mock workspace API +vi.mock('@/api/workspace', () => ({ + createWorkspace: vi.fn(), +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('CreateWorkspaceModal', () => { + const mockOnWorkspaceCreated = vi.fn(); + const mockNotificationsShow = vi.mocked(notifications.show); + const mockUseModalContext = vi.mocked(useModalContext); + const mockCreateWorkspace = vi.mocked(createWorkspace); + + const mockSetCreateWorkspaceModalVisible = vi.fn(); + const mockModalContext = { + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: true, + setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible, + }; + + const mockWorkspace: Workspace = { + id: 1, + userId: 1, + name: 'test-workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreateWorkspace.mockResolvedValue(mockWorkspace); + mockOnWorkspaceCreated.mockResolvedValue(undefined); + mockSetCreateWorkspaceModalVisible.mockClear(); + mockNotificationsShow.mockClear(); + mockUseModalContext.mockReturnValue(mockModalContext); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render( + + ); + + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-name-input')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /create/i }) + ).toBeInTheDocument(); + }); + + it('does not render when modal is closed', () => { + mockUseModalContext.mockReturnValueOnce({ + ...mockModalContext, + createWorkspaceModalVisible: false, + }); + + render( + + ); + + expect( + screen.queryByText('Create New Workspace') + ).not.toBeInTheDocument(); + }); + }); + + describe('User Actions', () => { + it('calls onClose when cancel button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('cancel-create-workspace-button')); + expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false); + }); + + it('updates workspace name input when typed', () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + fireEvent.change(nameInput, { target: { value: 'my-workspace' } }); + + expect((nameInput as HTMLInputElement).value).toBe('my-workspace'); + }); + }); + + describe('Form Validation', () => { + it('prevents submission with empty or whitespace-only names', async () => { + const testCases = ['', ' ', '\t\n ']; + + for (const testValue of testCases) { + const { unmount } = render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: testValue } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Workspace name is required', + color: 'red', + }); + }); + + expect(mockCreateWorkspace).not.toHaveBeenCalled(); + + unmount(); + vi.clearAllMocks(); + } + }); + + it('trims whitespace from workspace names before submission', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: ' valid-workspace ' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('valid-workspace'); + }); + }); + + it('accepts various valid workspace name formats', async () => { + const validNames = [ + 'simple', + 'workspace-with-dashes', + 'workspace_with_underscores', + 'workspace with spaces', + 'workspace123', + 'ワークスペース', // Unicode + ]; + + for (const name of validNames) { + const { unmount } = render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: name } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith(name); + }); + + unmount(); + vi.clearAllMocks(); + mockCreateWorkspace.mockResolvedValue(mockWorkspace); + } + }); + }); + + describe('Loading States and UI Behavior', () => { + it('disables form elements and shows loading during workspace creation', async () => { + mockCreateWorkspace.mockImplementation(() => new Promise(() => {})); // Never resolves + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + const cancelButton = screen.getByTestId('cancel-create-workspace-button'); + + fireEvent.change(nameInput, { target: { value: 'loading-test' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(nameInput).toBeDisabled(); + expect(createButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + expect(createButton).toHaveAttribute('data-loading', 'true'); + }); + }); + + it('maintains normal state when not loading', () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + const cancelButton = screen.getByTestId('cancel-create-workspace-button'); + + expect(nameInput).not.toBeDisabled(); + expect(createButton).not.toBeDisabled(); + expect(cancelButton).not.toBeDisabled(); + expect(createButton).not.toHaveAttribute('data-loading', 'true'); + }); + }); + + describe('Workspace Creation Flow', () => { + it('completes full successful creation flow', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'new-workspace' } }); + fireEvent.click(createButton); + + // API called with correct name + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('new-workspace'); + }); + + // Success notification shown + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Success', + message: 'Workspace created successfully', + color: 'green', + }); + }); + + // Callback invoked + await waitFor(() => { + expect(mockOnWorkspaceCreated).toHaveBeenCalledWith(mockWorkspace); + }); + + // Modal closed and form cleared + await waitFor(() => { + expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false); + expect((nameInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('works without onWorkspaceCreated callback', async () => { + render(); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'no-callback-test' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('no-callback-test'); + }); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Success', + message: 'Workspace created successfully', + color: 'green', + }); + }); + }); + }); + + describe('Error Handling', () => { + it('handles API errors gracefully', async () => { + mockCreateWorkspace.mockRejectedValue(new Error('Creation failed')); + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'error-workspace' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to create workspace', + color: 'red', + }); + }); + + // Modal remains open and form retains values + expect(mockSetCreateWorkspaceModalVisible).not.toHaveBeenCalledWith( + false + ); + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + expect((nameInput as HTMLInputElement).value).toBe('error-workspace'); + }); + + it('resets loading state after error', async () => { + mockCreateWorkspace.mockRejectedValue(new Error('Network error')); + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'loading-error' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(createButton).not.toHaveAttribute('data-loading', 'true'); + expect(nameInput).not.toBeDisabled(); + }); + }); + }); + + describe('Keyboard Interactions', () => { + it('supports keyboard input in the name field', () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + + expect(nameInput).not.toHaveAttribute('disabled'); + expect(nameInput).not.toHaveAttribute('readonly'); + + fireEvent.change(nameInput, { target: { value: 'keyboard-test' } }); + expect((nameInput as HTMLInputElement).value).toBe('keyboard-test'); + }); + }); +}); diff --git a/app/src/components/modals/workspace/CreateWorkspaceModal.tsx b/app/src/components/modals/workspace/CreateWorkspaceModal.tsx index 1d218d5..271a3fb 100644 --- a/app/src/components/modals/workspace/CreateWorkspaceModal.tsx +++ b/app/src/components/modals/workspace/CreateWorkspaceModal.tsx @@ -18,7 +18,8 @@ const CreateWorkspaceModal: React.FC = ({ useModalContext(); const handleSubmit = async (): Promise => { - if (!name.trim()) { + const trimmedName = name.trim(); + if (!trimmedName) { notifications.show({ title: 'Error', message: 'Workspace name is required', @@ -29,7 +30,7 @@ const CreateWorkspaceModal: React.FC = ({ setLoading(true); try { - const workspace = await createWorkspace(name); + const workspace = await createWorkspace(trimmedName); notifications.show({ title: 'Success', message: 'Workspace created successfully', @@ -61,8 +62,10 @@ const CreateWorkspaceModal: React.FC = ({ > setName(event.currentTarget.value)} mb="md" @@ -74,10 +77,15 @@ const CreateWorkspaceModal: React.FC = ({ variant="default" onClick={() => setCreateWorkspaceModalVisible(false)} disabled={loading} + data-testid="cancel-create-workspace-button" > Cancel - diff --git a/app/src/components/modals/workspace/DeleteWorkspaceModal.test.tsx b/app/src/components/modals/workspace/DeleteWorkspaceModal.test.tsx new file mode 100644 index 0000000..bf7f093 --- /dev/null +++ b/app/src/components/modals/workspace/DeleteWorkspaceModal.test.tsx @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import DeleteWorkspaceModal from './DeleteWorkspaceModal'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('DeleteWorkspaceModal', () => { + const mockOnConfirm = vi.fn(); + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + mockOnConfirm.mockResolvedValue(undefined); + }); + + describe('Modal Visibility and Content', () => { + it('renders modal with correct content when opened', () => { + render( + + ); + + expect(screen.getByText('Delete Workspace')).toBeInTheDocument(); + expect( + screen.getByText( + 'Are you sure you want to delete workspace "test-workspace"? This action cannot be undone and all files in this workspace will be permanently deleted.' + ) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /delete/i }) + ).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render( + + ); + + expect(screen.queryByText('Delete Workspace')).not.toBeInTheDocument(); + }); + + it('toggles visibility correctly when opened prop changes', () => { + const { rerender } = render( + + ); + + expect(screen.queryByText('Delete Workspace')).not.toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByText('Delete Workspace')).toBeInTheDocument(); + }); + }); + + describe('Workspace Name Display', () => { + it('displays various workspace name formats correctly', () => { + const testCases = [ + 'simple', + 'workspace-with-dashes', + 'workspace_with_underscores', + 'workspace with spaces', + 'workspace"with@quotes', + 'ワークスペース', // Unicode + '', // Empty string + undefined, // Undefined + ]; + + testCases.forEach((workspaceName) => { + const { unmount } = render( + + ); + + const displayName = workspaceName || ''; + expect( + screen.getByText( + `Are you sure you want to delete workspace "${displayName}"?`, + { exact: false } + ) + ).toBeInTheDocument(); + + unmount(); + }); + }); + }); + + describe('User Actions', () => { + it('calls onConfirm when delete button is clicked', async () => { + render( + + ); + + const deleteButton = screen.getByTestId( + 'confirm-delete-workspace-button' + ); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + }); + + it('calls onClose when cancel button is clicked', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-delete-workspace-button'); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('handles multiple rapid clicks gracefully', async () => { + render( + + ); + + const deleteButton = screen.getByTestId( + 'confirm-delete-workspace-button' + ); + + // Rapidly click multiple times + fireEvent.click(deleteButton); + fireEvent.click(deleteButton); + fireEvent.click(deleteButton); + + // Component should remain stable + expect(screen.getByText('Delete Workspace')).toBeInTheDocument(); + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalled(); + }); + }); + }); + + describe('Error Handling', () => { + it('handles deletion errors gracefully without crashing', async () => { + mockOnConfirm.mockRejectedValue(new Error('Deletion failed')); + + render( + + ); + + const deleteButton = screen.getByTestId( + 'confirm-delete-workspace-button' + ); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalled(); + }); + + // Component should remain stable after error + expect(screen.getByText('Delete Workspace')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/modals/workspace/DeleteWorkspaceModal.tsx b/app/src/components/modals/workspace/DeleteWorkspaceModal.tsx index e7e5d08..10cef0f 100644 --- a/app/src/components/modals/workspace/DeleteWorkspaceModal.tsx +++ b/app/src/components/modals/workspace/DeleteWorkspaceModal.tsx @@ -28,11 +28,19 @@ const DeleteWorkspaceModal: React.FC = ({ permanently deleted. - - diff --git a/app/src/components/navigation/UserMenu.test.tsx b/app/src/components/navigation/UserMenu.test.tsx new file mode 100644 index 0000000..a1f744b --- /dev/null +++ b/app/src/components/navigation/UserMenu.test.tsx @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../test/utils'; +import UserMenu from './UserMenu'; +import { UserRole } from '../../types/models'; + +// Mock the contexts +vi.mock('../../contexts/AuthContext', () => ({ + useAuth: vi.fn(), +})); + +// Mock the settings components +vi.mock('../settings/account/AccountSettings', () => ({ + default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => ( +
+ +
+ ), +})); + +vi.mock('../settings/admin/AdminDashboard', () => ({ + default: ({ opened, onClose }: { opened: boolean; onClose: () => void }) => ( +
+ +
+ ), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('UserMenu', () => { + const mockLogout = vi.fn(); + const mockUser = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useAuth } = await import('../../contexts/AuthContext'); + vi.mocked(useAuth).mockReturnValue({ + user: mockUser, + logout: mockLogout, + loading: false, + initialized: true, + login: vi.fn(), + refreshToken: vi.fn(), + refreshUser: vi.fn(), + }); + }); + + it('renders user avatar and shows user info when clicked', async () => { + const { getByLabelText, getByText } = render( + + + + ); + + // Find and click the avatar + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + // Check if user info is displayed in popover + await waitFor(() => { + expect(getByText('Test User')).toBeInTheDocument(); + }); + }); + + it('shows admin dashboard option for admin users only', async () => { + // Test admin user sees admin option + const { useAuth } = await import('../../contexts/AuthContext'); + vi.mocked(useAuth).mockReturnValue({ + user: { ...mockUser, role: UserRole.Admin }, + logout: mockLogout, + loading: false, + initialized: true, + login: vi.fn(), + refreshToken: vi.fn(), + refreshUser: vi.fn(), + }); + + const { getByLabelText, getByText } = render( + + + + ); + + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + await waitFor(() => { + expect(getByText('Admin Dashboard')).toBeInTheDocument(); + }); + }); + + it('opens account settings modal when clicked', async () => { + const { getByLabelText, getByText, getByTestId } = render( + + + + ); + + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + await waitFor(() => { + const accountSettingsButton = getByText('Account Settings'); + fireEvent.click(accountSettingsButton); + }); + + await waitFor(() => { + const modal = getByTestId('account-settings-modal'); + expect(modal).toHaveAttribute('data-opened', 'true'); + }); + }); + + it('calls logout when logout button is clicked', async () => { + const { getByLabelText, getByText } = render( + + + + ); + + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + await waitFor(() => { + const logoutButton = getByText('Logout'); + fireEvent.click(logoutButton); + }); + + expect(mockLogout).toHaveBeenCalledOnce(); + }); + + it('displays user email when displayName is not available', async () => { + const { useAuth } = await import('../../contexts/AuthContext'); + const userWithoutDisplayName = { + id: mockUser.id, + email: mockUser.email, + role: mockUser.role, + createdAt: mockUser.createdAt, + lastWorkspaceId: mockUser.lastWorkspaceId, + }; + + vi.mocked(useAuth).mockReturnValue({ + user: userWithoutDisplayName, + logout: mockLogout, + loading: false, + initialized: true, + login: vi.fn(), + refreshToken: vi.fn(), + refreshUser: vi.fn(), + }); + + const { getByLabelText, getByText } = render( + + + + ); + + const avatar = getByLabelText('User menu'); + fireEvent.click(avatar); + + await waitFor(() => { + expect(getByText('test@example.com')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/navigation/UserMenu.tsx b/app/src/components/navigation/UserMenu.tsx index c7177b8..6d30bca 100644 --- a/app/src/components/navigation/UserMenu.tsx +++ b/app/src/components/navigation/UserMenu.tsx @@ -47,6 +47,10 @@ const UserMenu: React.FC = () => { radius="xl" style={{ cursor: 'pointer' }} onClick={() => setOpened((o) => !o)} + aria-label="User menu" + aria-expanded={opened} + aria-haspopup="menu" + role="button" > diff --git a/app/src/components/navigation/WorkspaceSwitcher.test.tsx b/app/src/components/navigation/WorkspaceSwitcher.test.tsx new file mode 100644 index 0000000..00677f6 --- /dev/null +++ b/app/src/components/navigation/WorkspaceSwitcher.test.tsx @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../test/utils'; +import WorkspaceSwitcher from './WorkspaceSwitcher'; +import { Theme } from '../../types/models'; + +// Mock the hooks and contexts +vi.mock('../../hooks/useWorkspace', () => ({ + useWorkspace: vi.fn(), +})); + +vi.mock('../../contexts/ModalContext', () => ({ + useModalContext: vi.fn(), +})); + +// Mock API +vi.mock('../../api/workspace', () => ({ + listWorkspaces: vi.fn(), +})); + +// Mock the CreateWorkspaceModal component +vi.mock('../modals/workspace/CreateWorkspaceModal', () => ({ + default: ({ + onWorkspaceCreated, + }: { + onWorkspaceCreated: (workspace: { + name: string; + createdAt: number; + }) => void; + }) => ( +
+ +
+ ), +})); + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +describe('WorkspaceSwitcher', () => { + const mockSwitchWorkspace = vi.fn(); + const mockSetSettingsModalVisible = vi.fn(); + const mockSetCreateWorkspaceModalVisible = vi.fn(); + + const mockCurrentWorkspace = { + id: 1, + name: 'Current Workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }; + + const mockWorkspaces = [ + mockCurrentWorkspace, + { + id: 2, + name: 'Other Workspace', + createdAt: '2024-01-02T00:00:00Z', + theme: Theme.Dark, + autoSave: true, + showHiddenFiles: true, + gitEnabled: true, + gitUrl: 'https://github.com/test/repo', + gitUser: 'testuser', + gitToken: 'token', + gitAutoCommit: true, + gitCommitMsgTemplate: 'Auto: ${action} ${filename}', + gitCommitName: 'Test User', + gitCommitEmail: 'test@example.com', + }, + ]; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: mockCurrentWorkspace, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: mockSwitchWorkspace, + deleteCurrentWorkspace: vi.fn(), + }); + + const { useModalContext } = await import('../../contexts/ModalContext'); + vi.mocked(useModalContext).mockReturnValue({ + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: mockSetSettingsModalVisible, + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: false, + setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible, + }); + + const { listWorkspaces } = await import('../../api/workspace'); + vi.mocked(listWorkspaces).mockResolvedValue(mockWorkspaces); + }); + + it('renders current workspace name', () => { + const { getByText } = render( + + + + ); + + expect(getByText('Current Workspace')).toBeInTheDocument(); + }); + + it('shows "No workspace" when no current workspace', async () => { + const { useWorkspace } = await import('../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: null, + workspaces: [], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: mockSwitchWorkspace, + deleteCurrentWorkspace: vi.fn(), + }); + + const { getByText } = render( + + + + ); + + expect(getByText('No workspace')).toBeInTheDocument(); + }); + + it('opens popover and shows workspace list when clicked', async () => { + const { getByText } = render( + + + + ); + + // Click to open popover + const trigger = getByText('Current Workspace'); + fireEvent.click(trigger); + + // Should see the workspaces header and workspace list + await waitFor(() => { + expect(getByText('Workspaces')).toBeInTheDocument(); + expect(getByText('Other Workspace')).toBeInTheDocument(); + }); + }); + + it('switches workspace when another workspace is clicked', async () => { + const { getByText } = render( + + + + ); + + // Open popover and click on other workspace + const trigger = getByText('Current Workspace'); + fireEvent.click(trigger); + + await waitFor(() => { + const otherWorkspace = getByText('Other Workspace'); + fireEvent.click(otherWorkspace); + }); + + expect(mockSwitchWorkspace).toHaveBeenCalledWith('Other Workspace'); + }); + + it('opens create workspace modal when create button is clicked', async () => { + const { getByText, getByLabelText } = render( + + + + ); + + // Open popover and click create button + const trigger = getByText('Current Workspace'); + fireEvent.click(trigger); + + await waitFor(() => { + const createButton = getByLabelText('Create New Workspace'); + fireEvent.click(createButton); + }); + + expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(true); + }); + + it('opens settings modal when settings button is clicked', async () => { + const { getByText, getByLabelText } = render( + + + + ); + + // Open popover and click settings button + const trigger = getByText('Current Workspace'); + fireEvent.click(trigger); + + await waitFor(() => { + const settingsButton = getByLabelText('Workspace Settings'); + fireEvent.click(settingsButton); + }); + + expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(true); + }); +}); diff --git a/app/src/components/navigation/WorkspaceSwitcher.tsx b/app/src/components/navigation/WorkspaceSwitcher.tsx index 74d2722..21f0f4d 100644 --- a/app/src/components/navigation/WorkspaceSwitcher.tsx +++ b/app/src/components/navigation/WorkspaceSwitcher.tsx @@ -95,6 +95,7 @@ const WorkspaceSwitcher: React.FC = () => { @@ -152,6 +153,7 @@ const WorkspaceSwitcher: React.FC = () => { variant="subtle" size="lg" color={getConditionalColor(theme, true)} + aria-label="Workspace Settings" onClick={(e) => { e.stopPropagation(); setSettingsModalVisible(true); diff --git a/app/src/components/settings/AccordionControl.test.tsx b/app/src/components/settings/AccordionControl.test.tsx new file mode 100644 index 0000000..f6eb75a --- /dev/null +++ b/app/src/components/settings/AccordionControl.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider, Accordion } from '@mantine/core'; +import AccordionControl from './AccordionControl'; + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +// Test wrapper component to properly provide Accordion context +const AccordionWrapper: React.FC<{ + children: React.ReactNode; + defaultValue?: string[]; +}> = ({ children, defaultValue = ['test'] }) => ( + + {children} + +); + +describe('AccordionControl', () => { + describe('Normal Operation', () => { + it('renders children as Title with order 4', () => { + render( + + Settings Title + + ); + + const title = screen.getByRole('heading', { level: 4 }); + expect(title).toHaveTextContent('Settings Title'); + }); + + it('renders complex children correctly', () => { + render( + + + Complex Content + + + ); + + expect(screen.getByTestId('complex-child')).toBeInTheDocument(); + expect(screen.getByText('Complex')).toBeInTheDocument(); + }); + + it('functions as accordion control', () => { + render( + + Toggle Section + Hidden Content + + ); + + const control = screen.getByRole('button'); + fireEvent.click(control); + + expect(screen.getByText('Hidden Content')).toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + it('handles empty children gracefully', () => { + render( + + {''} + + ); + + const title = screen.getByRole('heading', { level: 4 }); + expect(title).toBeInTheDocument(); + }); + + it('passes through children props correctly', () => { + const mockClickHandler = vi.fn(); + + render( + + + + + + ); + + const innerButton = screen.getByTestId('inner-button'); + fireEvent.click(innerButton); + expect(mockClickHandler).toHaveBeenCalled(); + }); + }); + + describe('Accessibility', () => { + it('provides proper semantic structure', () => { + render( + + Accessible Title + + ); + + const title = screen.getByRole('heading', { level: 4 }); + const button = screen.getByRole('button'); + + expect(title).toHaveTextContent('Accessible Title'); + expect(button).toContainElement(title); + }); + }); +}); diff --git a/app/src/components/settings/account/AccountSettings.test.tsx b/app/src/components/settings/account/AccountSettings.test.tsx new file mode 100644 index 0000000..cdf60ff --- /dev/null +++ b/app/src/components/settings/account/AccountSettings.test.tsx @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import AccountSettings from './AccountSettings'; + +// Mock the auth context +const mockUser = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: 'editor' as const, +}; +const mockRefreshUser = vi.fn(); +vi.mock('../../../contexts/AuthContext', () => ({ + useAuth: () => ({ + user: mockUser, + refreshUser: mockRefreshUser, + }), +})); + +// Mock the profile settings hook +const mockUpdateProfile = vi.fn(); +vi.mock('../../../hooks/useProfileSettings', () => ({ + useProfileSettings: () => ({ + loading: false, + updateProfile: mockUpdateProfile, + }), +})); + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock the sub-components +vi.mock('./ProfileSettings', () => ({ + default: ({ + settings, + onInputChange, + }: { + settings: { + displayName?: string; + email?: string; + }; + onInputChange: (field: string, value: string) => void; + }) => ( +
+ onInputChange('displayName', e.target.value)} + /> + onInputChange('email', e.target.value)} + /> +
+ ), +})); + +vi.mock('./SecuritySettings', () => ({ + default: ({ + settings, + onInputChange, + }: { + settings: { + currentPassword?: string; + newPassword?: string; + }; + onInputChange: (field: string, value: string) => void; + }) => ( +
+ onInputChange('currentPassword', e.target.value)} + /> + onInputChange('newPassword', e.target.value)} + /> +
+ ), +})); + +vi.mock('./DangerZoneSettings', () => ({ + default: () =>
Danger Zone
, +})); + +vi.mock('../../modals/account/EmailPasswordModal', () => ({ + default: ({ + opened, + onConfirm, + }: { + opened: boolean; + onConfirm: (password: string) => void; + }) => + opened ? ( +
+ +
+ ) : null, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('AccountSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUpdateProfile.mockResolvedValue(mockUser); + mockRefreshUser.mockResolvedValue(undefined); + }); + + it('renders modal with all sections', () => { + render(); + + expect(screen.getByText('Account Settings')).toBeInTheDocument(); + expect(screen.getByTestId('profile-settings')).toBeInTheDocument(); + expect(screen.getByTestId('security-settings')).toBeInTheDocument(); + expect(screen.getByTestId('danger-zone-settings')).toBeInTheDocument(); + }); + + it('shows unsaved changes badge when settings are modified', () => { + render(); + + const displayNameInput = screen.getByTestId('display-name-input'); + fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } }); + + expect(screen.getByText('Unsaved Changes')).toBeInTheDocument(); + }); + + it('enables save button when there are changes', () => { + render(); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + expect(saveButton).toBeDisabled(); + + const displayNameInput = screen.getByTestId('display-name-input'); + fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } }); + + expect(saveButton).not.toBeDisabled(); + }); + + it('saves profile changes successfully', async () => { + const mockOnClose = vi.fn(); + render(); + + const displayNameInput = screen.getByTestId('display-name-input'); + fireEvent.change(displayNameInput, { target: { value: 'Updated Name' } }); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockUpdateProfile).toHaveBeenCalledWith( + expect.objectContaining({ displayName: 'Updated Name' }) + ); + }); + + await waitFor(() => { + expect(mockRefreshUser).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('opens email confirmation modal for email changes', () => { + render(); + + const emailInput = screen.getByTestId('email-input'); + fireEvent.change(emailInput, { target: { value: 'new@example.com' } }); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + fireEvent.click(saveButton); + + expect(screen.getByTestId('email-password-modal')).toBeInTheDocument(); + }); + + it('completes email change with password confirmation', async () => { + const mockOnClose = vi.fn(); + render(); + + const emailInput = screen.getByTestId('email-input'); + fireEvent.change(emailInput, { target: { value: 'new@example.com' } }); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + fireEvent.click(saveButton); + + const confirmButton = screen.getByTestId('confirm-email'); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockUpdateProfile).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'new@example.com', + currentPassword: 'test-password', + }) + ); + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('closes modal when cancel is clicked', () => { + const mockOnClose = vi.fn(); + render(); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('does not render when closed', () => { + render(); + + expect(screen.queryByText('Account Settings')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/account/AccountSettings.tsx b/app/src/components/settings/account/AccountSettings.tsx index 5464429..c4c87b6 100644 --- a/app/src/components/settings/account/AccountSettings.tsx +++ b/app/src/components/settings/account/AccountSettings.tsx @@ -153,7 +153,7 @@ const AccountSettings: React.FC = ({ } }; - const handleEmailConfirm = async (password: string): Promise => { + const handleEmailConfirm = async (password: string): Promise => { const updates: UserProfileSettings = { ...state.localSettings, currentPassword: password, @@ -181,6 +181,11 @@ const AccountSettings: React.FC = ({ dispatch({ type: SettingsActionType.MARK_SAVED }); setEmailModalOpened(false); onClose(); + return true; + } else { + // TODO: Handle errors appropriately + // notifications.show({... + return false; } }; @@ -238,7 +243,7 @@ const AccountSettings: React.FC = ({ Cancel + + + ) : null, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('DangerZoneSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDeleteAccount.mockResolvedValue(true); + mockLogout.mockResolvedValue(undefined); + }); + + it('renders delete button with warning text', () => { + render(); + + expect( + screen.getByRole('button', { name: 'Delete Account' }) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Once you delete your account, there is no going back. Please be certain.' + ) + ).toBeInTheDocument(); + }); + + it('opens and closes delete modal', () => { + render(); + + const deleteButton = screen.getByRole('button', { name: 'Delete Account' }); + fireEvent.click(deleteButton); + + expect(screen.getByTestId('delete-account-modal')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('modal-close')); + expect( + screen.queryByTestId('delete-account-modal') + ).not.toBeInTheDocument(); + }); + + it('completes account deletion and logout flow', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.click(screen.getByTestId('modal-confirm')); + + await waitFor(() => { + expect(mockDeleteAccount).toHaveBeenCalledWith('test-password'); + }); + + await waitFor(() => { + expect(mockLogout).toHaveBeenCalled(); + }); + + expect( + screen.queryByTestId('delete-account-modal') + ).not.toBeInTheDocument(); + }); + + it('keeps modal open when deletion fails', async () => { + mockDeleteAccount.mockResolvedValue(false); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.click(screen.getByTestId('modal-confirm')); + + await waitFor(() => { + expect(mockDeleteAccount).toHaveBeenCalled(); + }); + + expect(screen.getByTestId('delete-account-modal')).toBeInTheDocument(); + expect(mockLogout).not.toHaveBeenCalled(); + }); + + it('allows cancellation of deletion process', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Account' })); + fireEvent.click(screen.getByTestId('modal-close')); + + expect( + screen.queryByTestId('delete-account-modal') + ).not.toBeInTheDocument(); + expect(mockDeleteAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/settings/account/ProfileSettings.test.tsx b/app/src/components/settings/account/ProfileSettings.test.tsx new file mode 100644 index 0000000..124022d --- /dev/null +++ b/app/src/components/settings/account/ProfileSettings.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import ProfileSettings from './ProfileSettings'; +import type { UserProfileSettings } from '@/types/models'; + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('ProfileSettings', () => { + const mockOnInputChange = vi.fn(); + + const defaultSettings: UserProfileSettings = { + displayName: 'John Doe', + email: 'john.doe@example.com', + currentPassword: '', + newPassword: '', + }; + + const emptySettings: UserProfileSettings = { + displayName: '', + email: '', + currentPassword: '', + newPassword: '', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders form fields with current values', () => { + render( + + ); + + const displayNameInput = screen.getByTestId('display-name-input'); + const emailInput = screen.getByTestId('email-input'); + + expect(displayNameInput).toHaveValue('John Doe'); + expect(emailInput).toHaveValue('john.doe@example.com'); + }); + + it('renders with empty settings', () => { + render( + + ); + + const displayNameInput = screen.getByTestId('display-name-input'); + const emailInput = screen.getByTestId('email-input'); + + expect(displayNameInput).toHaveValue(''); + expect(emailInput).toHaveValue(''); + }); + + it('calls onInputChange when display name is modified', () => { + render( + + ); + + const displayNameInput = screen.getByTestId('display-name-input'); + fireEvent.change(displayNameInput, { target: { value: 'Jane Smith' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith('displayName', 'Jane Smith'); + }); + + it('calls onInputChange when email is modified', () => { + render( + + ); + + const emailInput = screen.getByTestId('email-input'); + fireEvent.change(emailInput, { target: { value: 'jane@example.com' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith('email', 'jane@example.com'); + }); + + it('has correct input types and accessibility', () => { + render( + + ); + + const displayNameInput = screen.getByTestId('display-name-input'); + const emailInput = screen.getByTestId('email-input'); + + expect(displayNameInput).toHaveAttribute('type', 'text'); + expect(emailInput).toHaveAttribute('type', 'email'); + expect(displayNameInput).toHaveAccessibleName(); + expect(emailInput).toHaveAccessibleName(); + }); +}); diff --git a/app/src/components/settings/account/ProfileSettings.tsx b/app/src/components/settings/account/ProfileSettings.tsx index 52851c4..9c85adc 100644 --- a/app/src/components/settings/account/ProfileSettings.tsx +++ b/app/src/components/settings/account/ProfileSettings.tsx @@ -7,7 +7,7 @@ interface ProfileSettingsProps { onInputChange: (key: keyof UserProfileSettings, value: string) => void; } -const ProfileSettingsComponent: React.FC = ({ +const ProfileSettings: React.FC = ({ settings, onInputChange, }) => ( @@ -15,18 +15,22 @@ const ProfileSettingsComponent: React.FC = ({ onInputChange('displayName', e.currentTarget.value)} placeholder="Enter display name" + data-testid="display-name-input" /> onInputChange('email', e.currentTarget.value)} placeholder="Enter email" + data-testid="email-input" />
); -export default ProfileSettingsComponent; +export default ProfileSettings; diff --git a/app/src/components/settings/account/SecuritySettings.test.tsx b/app/src/components/settings/account/SecuritySettings.test.tsx new file mode 100644 index 0000000..8a999aa --- /dev/null +++ b/app/src/components/settings/account/SecuritySettings.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import SecuritySettings from './SecuritySettings'; +import type { UserProfileSettings } from '@/types/models'; + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('SecuritySettings', () => { + const mockOnInputChange = vi.fn(); + + const defaultSettings: UserProfileSettings = { + displayName: 'John Doe', + email: 'john@example.com', + currentPassword: '', + newPassword: '', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all password fields', () => { + render( + + ); + + expect(screen.getByLabelText('Current Password')).toBeInTheDocument(); + expect(screen.getByLabelText('New Password')).toBeInTheDocument(); + expect(screen.getByLabelText('Confirm New Password')).toBeInTheDocument(); + }); + + it('calls onInputChange for current password', () => { + render( + + ); + + const currentPasswordInput = screen.getByLabelText('Current Password'); + fireEvent.change(currentPasswordInput, { target: { value: 'oldpass123' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith( + 'currentPassword', + 'oldpass123' + ); + }); + + it('calls onInputChange for new password', () => { + render( + + ); + + const newPasswordInput = screen.getByLabelText('New Password'); + fireEvent.change(newPasswordInput, { target: { value: 'newpass123' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith('newPassword', 'newpass123'); + }); + + it('shows error when passwords do not match', () => { + render( + + ); + + const confirmPasswordInput = screen.getByLabelText('Confirm New Password'); + fireEvent.change(confirmPasswordInput, { + target: { value: 'different123' }, + }); + + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + }); + + it('clears error when passwords match', () => { + render( + + ); + + const confirmPasswordInput = screen.getByLabelText('Confirm New Password'); + + // First make them not match + fireEvent.change(confirmPasswordInput, { + target: { value: 'different123' }, + }); + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + + // Then make them match + fireEvent.change(confirmPasswordInput, { + target: { value: 'password123' }, + }); + expect( + screen.queryByText('Passwords do not match') + ).not.toBeInTheDocument(); + }); + + it('has correct input types and help text', () => { + render( + + ); + + const currentPasswordInput = screen.getByLabelText('Current Password'); + const newPasswordInput = screen.getByLabelText('New Password'); + const confirmPasswordInput = screen.getByLabelText('Confirm New Password'); + + expect(currentPasswordInput).toHaveAttribute('type', 'password'); + expect(newPasswordInput).toHaveAttribute('type', 'password'); + expect(confirmPasswordInput).toHaveAttribute('type', 'password'); + + expect( + screen.getByText(/Password must be at least 8 characters long/) + ).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/account/SecuritySettings.tsx b/app/src/components/settings/account/SecuritySettings.tsx index ea62594..6f726a6 100644 --- a/app/src/components/settings/account/SecuritySettings.tsx +++ b/app/src/components/settings/account/SecuritySettings.tsx @@ -41,6 +41,7 @@ const SecuritySettings: React.FC = ({ handlePasswordChange('currentPassword', e.currentTarget.value) @@ -49,6 +50,7 @@ const SecuritySettings: React.FC = ({ /> handlePasswordChange('newPassword', e.currentTarget.value) @@ -57,6 +59,7 @@ const SecuritySettings: React.FC = ({ /> handlePasswordChange('confirmNewPassword', e.currentTarget.value) diff --git a/app/src/components/settings/admin/AdminDashboard.test.tsx b/app/src/components/settings/admin/AdminDashboard.test.tsx new file mode 100644 index 0000000..fd439a5 --- /dev/null +++ b/app/src/components/settings/admin/AdminDashboard.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import AdminDashboard from './AdminDashboard'; +import { UserRole, type User } from '@/types/models'; + +// Mock the auth context +const mockCurrentUser: User = { + id: 1, + email: 'admin@example.com', + displayName: 'Admin User', + role: UserRole.Admin, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, +}; + +vi.mock('../../../contexts/AuthContext', () => ({ + useAuth: () => ({ + user: mockCurrentUser, + }), +})); + +// Mock the sub-components +vi.mock('./AdminUsersTab', () => ({ + default: ({ currentUser }: { currentUser: User }) => ( +
Users Tab - {currentUser.email}
+ ), +})); + +vi.mock('./AdminWorkspacesTab', () => ({ + default: () =>
Workspaces Tab
, +})); + +vi.mock('./AdminStatsTab', () => ({ + default: () =>
Stats Tab
, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('AdminDashboard', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders modal with all tabs', () => { + render(); + + expect(screen.getByText('Admin Dashboard')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /users/i })).toBeInTheDocument(); + expect( + screen.getByRole('tab', { name: /workspaces/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('tab', { name: /statistics/i }) + ).toBeInTheDocument(); + }); + + it('shows users tab by default', () => { + render(); + + expect(screen.getByTestId('admin-users-tab')).toBeInTheDocument(); + expect( + screen.getByText('Users Tab - admin@example.com') + ).toBeInTheDocument(); + }); + + it('switches to workspaces tab when clicked', () => { + render(); + + fireEvent.click(screen.getByRole('tab', { name: /workspaces/i })); + + expect(screen.getByTestId('admin-workspaces-tab')).toBeInTheDocument(); + expect(screen.getByText('Workspaces Tab')).toBeInTheDocument(); + }); + + it('switches to statistics tab when clicked', () => { + render(); + + fireEvent.click(screen.getByRole('tab', { name: /statistics/i })); + + expect(screen.getByTestId('admin-stats-tab')).toBeInTheDocument(); + expect(screen.getByText('Stats Tab')).toBeInTheDocument(); + }); + + it('passes current user to users tab', () => { + render(); + + // Should pass current user to AdminUsersTab + expect( + screen.getByText('Users Tab - admin@example.com') + ).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render(); + + expect(screen.queryByText('Admin Dashboard')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/admin/AdminStatsTab.test.tsx b/app/src/components/settings/admin/AdminStatsTab.test.tsx new file mode 100644 index 0000000..04367b3 --- /dev/null +++ b/app/src/components/settings/admin/AdminStatsTab.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import AdminStatsTab from './AdminStatsTab'; +import type { SystemStats } from '@/types/models'; + +// Mock the admin data hook +vi.mock('../../../hooks/useAdminData', () => ({ + useAdminData: vi.fn(), +})); + +// Mock the formatBytes utility +vi.mock('../../../utils/formatBytes', () => ({ + formatBytes: (bytes: number) => `${bytes} bytes`, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('AdminStatsTab', () => { + const mockStats: SystemStats = { + totalUsers: 150, + activeUsers: 120, + totalWorkspaces: 85, + totalFiles: 2500, + totalSize: 1073741824, // 1GB in bytes + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { useAdminData } = await import('../../../hooks/useAdminData'); + vi.mocked(useAdminData).mockReturnValue({ + data: mockStats, + loading: false, + error: null, + reload: vi.fn(), + }); + }); + + it('renders statistics table with all metrics', () => { + render(); + + expect(screen.getByText('System Statistics')).toBeInTheDocument(); + expect(screen.getByText('Total Users')).toBeInTheDocument(); + expect(screen.getByText('Active Users')).toBeInTheDocument(); + expect(screen.getByText('Total Workspaces')).toBeInTheDocument(); + expect(screen.getByText('Total Files')).toBeInTheDocument(); + expect(screen.getByText('Total Storage Size')).toBeInTheDocument(); + }); + + it('displays correct statistics values', () => { + render(); + + expect(screen.getByText('150')).toBeInTheDocument(); + expect(screen.getByText('120')).toBeInTheDocument(); + expect(screen.getByText('85')).toBeInTheDocument(); + expect(screen.getByText('2500')).toBeInTheDocument(); + expect(screen.getByText('1073741824 bytes')).toBeInTheDocument(); + }); + + it('shows loading state', async () => { + const { useAdminData } = await import('../../../hooks/useAdminData'); + vi.mocked(useAdminData).mockReturnValue({ + data: {} as SystemStats, + loading: true, + error: null, + reload: vi.fn(), + }); + + render(); + + // Mantine LoadingOverlay should be visible + expect( + document.querySelector('.mantine-LoadingOverlay-root') + ).toBeInTheDocument(); + }); + + it('shows error state', async () => { + const { useAdminData } = await import('../../../hooks/useAdminData'); + vi.mocked(useAdminData).mockReturnValue({ + data: {} as SystemStats, + loading: false, + error: 'Failed to load statistics', + reload: vi.fn(), + }); + + render(); + + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Failed to load statistics')).toBeInTheDocument(); + }); + + it('handles zero values correctly', async () => { + const zeroStats: SystemStats = { + totalUsers: 0, + activeUsers: 0, + totalWorkspaces: 0, + totalFiles: 0, + totalSize: 0, + }; + + const { useAdminData } = await import('../../../hooks/useAdminData'); + vi.mocked(useAdminData).mockReturnValue({ + data: zeroStats, + loading: false, + error: null, + reload: vi.fn(), + }); + + render(); + + // Should display zeros without issues + const zeros = screen.getAllByText('0'); + expect(zeros.length).toBeGreaterThan(0); + expect(screen.getByText('0 bytes')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/admin/AdminUsersTab.test.tsx b/app/src/components/settings/admin/AdminUsersTab.test.tsx new file mode 100644 index 0000000..79c6ce5 --- /dev/null +++ b/app/src/components/settings/admin/AdminUsersTab.test.tsx @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import AdminUsersTab from './AdminUsersTab'; +import { UserRole, type User } from '@/types/models'; + +// Mock the user admin hook +const mockCreate = vi.fn(); +const mockUpdate = vi.fn(); +const mockDelete = vi.fn(); + +vi.mock('../../../hooks/useUserAdmin', () => ({ + useUserAdmin: vi.fn(), +})); + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock the user modals +vi.mock('../../modals/user/CreateUserModal', () => ({ + default: ({ + opened, + onCreateUser, + }: { + opened: boolean; + onCreateUser: (userData: { + email: string; + password: string; + displayName: string; + role: UserRole; + }) => Promise; + }) => + opened ? ( +
+ +
+ ) : null, +})); + +vi.mock('../../modals/user/EditUserModal', () => ({ + default: ({ + opened, + onEditUser, + user, + }: { + opened: boolean; + onEditUser: ( + userId: number, + userData: { email: string } + ) => Promise; + user: User | null; + }) => + opened ? ( +
+ {user?.email} + +
+ ) : null, +})); + +vi.mock('../../modals/user/DeleteUserModal', () => ({ + default: ({ + opened, + onConfirm, + }: { + opened: boolean; + onConfirm: () => Promise; + }) => + opened ? ( +
+ +
+ ) : null, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('AdminUsersTab', () => { + const mockCurrentUser: User = { + id: 1, + email: 'admin@example.com', + displayName: 'Admin User', + role: UserRole.Admin, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + const mockUsers: User[] = [ + mockCurrentUser, + { + id: 2, + email: 'editor@example.com', + displayName: 'Editor User', + role: UserRole.Editor, + createdAt: '2024-01-15T00:00:00Z', + lastWorkspaceId: 2, + }, + { + id: 3, + email: 'viewer@example.com', + displayName: 'Viewer User', + role: UserRole.Viewer, + createdAt: '2024-02-01T00:00:00Z', + lastWorkspaceId: 3, + }, + ]; + + beforeEach(async () => { + vi.clearAllMocks(); + mockCreate.mockResolvedValue(true); + mockUpdate.mockResolvedValue(true); + mockDelete.mockResolvedValue(true); + + const { useUserAdmin } = await import('../../../hooks/useUserAdmin'); + vi.mocked(useUserAdmin).mockReturnValue({ + users: mockUsers, + loading: false, + error: null, + create: mockCreate, + update: mockUpdate, + delete: mockDelete, + }); + }); + + it('renders users table with all users', () => { + render(); + + expect(screen.getByText('User Management')).toBeInTheDocument(); + expect(screen.getByText('admin@example.com')).toBeInTheDocument(); + expect(screen.getByText('editor@example.com')).toBeInTheDocument(); + expect(screen.getByText('viewer@example.com')).toBeInTheDocument(); + expect(screen.getByText('Admin User')).toBeInTheDocument(); + expect(screen.getByText('Editor User')).toBeInTheDocument(); + expect(screen.getByText('Viewer User')).toBeInTheDocument(); + }); + + it('shows create user button', () => { + render(); + + expect( + screen.getByRole('button', { name: /create user/i }) + ).toBeInTheDocument(); + }); + + it('opens create user modal when create button is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /create user/i })); + + expect(screen.getByTestId('create-user-modal')).toBeInTheDocument(); + }); + + it('creates new user successfully', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /create user/i })); + fireEvent.click(screen.getByTestId('create-user-button')); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith({ + email: 'new@example.com', + password: 'pass', + displayName: 'New User', + role: UserRole.Editor, + }); + }); + }); + + it('opens edit modal when edit button is clicked', () => { + render(); + + const editButtons = screen.getAllByLabelText(/edit/i); + expect(editButtons[0]).toBeDefined(); + fireEvent.click(editButtons[0]!); // Click first edit button + + expect(screen.getByTestId('edit-user-modal')).toBeInTheDocument(); + expect(screen.getByTestId('edit-user-email')).toHaveTextContent( + 'admin@example.com' + ); + }); + + it('updates user successfully', async () => { + render(); + + const editButtons = screen.getAllByLabelText(/edit/i); + expect(editButtons[0]).toBeDefined(); + fireEvent.click(editButtons[0]!); + fireEvent.click(screen.getByTestId('edit-user-button')); + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledWith(1, { + email: 'updated@example.com', + }); + }); + }); + + it('prevents deleting current user', () => { + render(); + + const deleteButtons = screen.getAllByLabelText(/delete/i); + const currentUserDeleteButton = deleteButtons[0]; // First user is current user + + expect(currentUserDeleteButton).toBeDefined(); + expect(currentUserDeleteButton).toBeDisabled(); + }); + + it('allows deleting other users', () => { + render(); + + const deleteButtons = screen.getAllByLabelText(/delete/i); + expect(deleteButtons[1]).toBeDefined(); + fireEvent.click(deleteButtons[1]!); // Click delete for second user + + expect(screen.getByTestId('delete-user-modal')).toBeInTheDocument(); + }); + + it('deletes user successfully', async () => { + render(); + + const deleteButtons = screen.getAllByLabelText(/delete/i); + expect(deleteButtons[1]).toBeDefined(); + fireEvent.click(deleteButtons[1]!); + fireEvent.click(screen.getByTestId('delete-user-button')); + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith(2); // Second user's ID + }); + }); + + it('shows error state when loading fails', async () => { + const { useUserAdmin } = await import('../../../hooks/useUserAdmin'); + vi.mocked(useUserAdmin).mockReturnValue({ + users: [], + loading: false, + error: 'Failed to load users', + create: mockCreate, + update: mockUpdate, + delete: mockDelete, + }); + + render(); + + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Failed to load users')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/admin/AdminUsersTab.tsx b/app/src/components/settings/admin/AdminUsersTab.tsx index 82f1ed3..edd1b29 100644 --- a/app/src/components/settings/admin/AdminUsersTab.tsx +++ b/app/src/components/settings/admin/AdminUsersTab.tsx @@ -86,6 +86,7 @@ const AdminUsersTab: React.FC = ({ currentUser }) => { setEditModalData(user)} > @@ -93,6 +94,7 @@ const AdminUsersTab: React.FC = ({ currentUser }) => { handleDeleteClick(user)} disabled={user.id === currentUser.id} @@ -125,6 +127,7 @@ const AdminUsersTab: React.FC = ({ currentUser }) => { + + + ) : null, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('DangerZoneSettings (Workspace)', () => { + beforeEach(async () => { + vi.clearAllMocks(); + mockDeleteCurrentWorkspace.mockResolvedValue(undefined); + + const { useWorkspace } = await import('../../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: { + id: 1, + userId: 1, + name: 'Test Workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }, + workspaces: [ + { + id: 1, + userId: 1, + name: 'Workspace 1', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }, + { + id: 2, + userId: 1, + name: 'Workspace 2', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }, + ], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: mockDeleteCurrentWorkspace, + }); + }); + + it('renders delete button when multiple workspaces exist', () => { + render(); + + const deleteButton = screen.getByRole('button', { + name: 'Delete Workspace', + }); + expect(deleteButton).toBeInTheDocument(); + expect(deleteButton).not.toBeDisabled(); + }); + + it('disables delete button when only one workspace exists', async () => { + const { useWorkspace } = await import('../../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: { + id: 1, + userId: 1, + name: 'Last Workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }, + workspaces: [ + { + id: 1, + userId: 1, + name: 'Last Workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }, + ], + updateSettings: vi.fn(), + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: mockDeleteCurrentWorkspace, + }); + + render(); + + const deleteButton = screen.getByRole('button', { + name: 'Delete Workspace', + }); + expect(deleteButton).toBeDisabled(); + expect(deleteButton).toHaveAttribute( + 'title', + 'Cannot delete the last workspace' + ); + }); + + it('opens and closes delete modal', () => { + render(); + + const deleteButton = screen.getByRole('button', { + name: 'Delete Workspace', + }); + fireEvent.click(deleteButton); + + expect(screen.getByTestId('delete-workspace-modal')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-name')).toHaveTextContent( + 'Test Workspace' + ); + + fireEvent.click(screen.getByTestId('modal-close')); + expect( + screen.queryByTestId('delete-workspace-modal') + ).not.toBeInTheDocument(); + }); + + it('completes workspace deletion flow', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Workspace' })); + fireEvent.click(screen.getByTestId('modal-confirm')); + + await waitFor(() => { + expect(mockDeleteCurrentWorkspace).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false); + }); + + expect( + screen.queryByTestId('delete-workspace-modal') + ).not.toBeInTheDocument(); + }); + + it('allows cancellation of deletion process', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete Workspace' })); + fireEvent.click(screen.getByTestId('modal-close')); + + expect( + screen.queryByTestId('delete-workspace-modal') + ).not.toBeInTheDocument(); + expect(mockDeleteCurrentWorkspace).not.toHaveBeenCalled(); + expect(mockSetSettingsModalVisible).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/settings/workspace/EditorSettings.test.tsx b/app/src/components/settings/workspace/EditorSettings.test.tsx new file mode 100644 index 0000000..3dc1d40 --- /dev/null +++ b/app/src/components/settings/workspace/EditorSettings.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import EditorSettings from './EditorSettings'; + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('EditorSettings', () => { + const mockOnAutoSaveChange = vi.fn(); + const mockOnShowHiddenFilesChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders both toggle switches with labels', () => { + render( + + ); + + expect(screen.getByText('Auto Save')).toBeInTheDocument(); + expect(screen.getByText('Show Hidden Files')).toBeInTheDocument(); + }); + + it('shows correct toggle states', () => { + render( + + ); + + const toggles = screen.getAllByRole('switch'); + const autoSaveToggle = toggles[0]; + const hiddenFilesToggle = toggles[1]; + + expect(autoSaveToggle).toBeChecked(); + expect(hiddenFilesToggle).not.toBeChecked(); + }); + + it('calls onShowHiddenFilesChange when toggle is clicked', () => { + render( + + ); + + // Get the show hidden files toggle by finding the one that's not disabled + const toggles = screen.getAllByRole('switch'); + const hiddenFilesToggle = toggles.find( + (toggle) => !toggle.hasAttribute('disabled') + ); + + expect(hiddenFilesToggle).toBeDefined(); + fireEvent.click(hiddenFilesToggle!); + + expect(mockOnShowHiddenFilesChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/app/src/components/settings/workspace/GeneralSettings.test.tsx b/app/src/components/settings/workspace/GeneralSettings.test.tsx new file mode 100644 index 0000000..0db590e --- /dev/null +++ b/app/src/components/settings/workspace/GeneralSettings.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import GeneralSettings from './GeneralSettings'; + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('GeneralSettings', () => { + const mockOnInputChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders workspace name input with current value', () => { + render( + + ); + + const nameInput = screen.getByDisplayValue('My Workspace'); + expect(nameInput).toBeInTheDocument(); + expect(screen.getByText('Workspace Name')).toBeInTheDocument(); + }); + + it('renders with empty name', () => { + render(); + + const nameInput = screen.getByPlaceholderText('Enter workspace name'); + expect(nameInput).toHaveValue(''); + }); + + it('calls onInputChange when name is modified', () => { + render( + + ); + + const nameInput = screen.getByDisplayValue('Old Name'); + fireEvent.change(nameInput, { target: { value: 'New Workspace Name' } }); + + expect(mockOnInputChange).toHaveBeenCalledWith( + 'name', + 'New Workspace Name' + ); + }); + + it('has required attribute on input', () => { + render(); + + const nameInput = screen.getByDisplayValue('Test'); + expect(nameInput).toHaveAttribute('required'); + }); +}); diff --git a/app/src/components/settings/workspace/GitSettings.test.tsx b/app/src/components/settings/workspace/GitSettings.test.tsx new file mode 100644 index 0000000..3439917 --- /dev/null +++ b/app/src/components/settings/workspace/GitSettings.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import GitSettings from './GitSettings'; + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('GitSettings', () => { + const mockOnInputChange = vi.fn(); + + const defaultProps = { + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + onInputChange: mockOnInputChange, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders all git settings fields', () => { + render(); + + expect(screen.getByText('Enable Git Repository')).toBeInTheDocument(); + expect(screen.getByText('Git URL')).toBeInTheDocument(); + expect(screen.getByText('Username')).toBeInTheDocument(); + expect(screen.getByText('Access Token')).toBeInTheDocument(); + expect(screen.getByText('Commit on Save')).toBeInTheDocument(); + expect(screen.getByText('Commit Message Template')).toBeInTheDocument(); + expect(screen.getByText('Commit Author')).toBeInTheDocument(); + expect(screen.getByText('Commit Author Email')).toBeInTheDocument(); + }); + + it('disables all inputs when git is not enabled', () => { + render(); + + expect(screen.getByPlaceholderText('Enter Git URL')).toBeDisabled(); + expect(screen.getByPlaceholderText('Enter Git username')).toBeDisabled(); + expect(screen.getByPlaceholderText('Enter Git token')).toBeDisabled(); + + const switches = screen.getAllByRole('switch'); + const commitOnSaveSwitch = switches[1]; // Second switch is commit on save + expect(commitOnSaveSwitch).toBeDisabled(); + }); + + it('enables all inputs when git is enabled', () => { + render(); + + expect(screen.getByPlaceholderText('Enter Git URL')).not.toBeDisabled(); + expect( + screen.getByPlaceholderText('Enter Git username') + ).not.toBeDisabled(); + expect(screen.getByPlaceholderText('Enter Git token')).not.toBeDisabled(); + + const switches = screen.getAllByRole('switch'); + const commitOnSaveSwitch = switches[1]; + expect(commitOnSaveSwitch).not.toBeDisabled(); + }); + + it('calls onInputChange when git enabled toggle is changed', () => { + render(); + + const switches = screen.getAllByRole('switch'); + const gitEnabledSwitch = switches[0]; + expect(gitEnabledSwitch).toBeDefined(); + + fireEvent.click(gitEnabledSwitch!); + + expect(mockOnInputChange).toHaveBeenCalledWith('gitEnabled', true); + }); + + it('calls onInputChange when git URL is changed', () => { + render(); + + const urlInput = screen.getByPlaceholderText('Enter Git URL'); + fireEvent.change(urlInput, { + target: { value: 'https://github.com/user/repo.git' }, + }); + + expect(mockOnInputChange).toHaveBeenCalledWith( + 'gitUrl', + 'https://github.com/user/repo.git' + ); + }); + + it('calls onInputChange when commit template is changed', () => { + render(); + + const templateInput = screen.getByPlaceholderText( + 'Enter commit message template' + ); + fireEvent.change(templateInput, { + target: { value: '${action}: ${filename}' }, + }); + + expect(mockOnInputChange).toHaveBeenCalledWith( + 'gitCommitMsgTemplate', + '${action}: ${filename}' + ); + }); + + it('shows current values in form fields', () => { + const propsWithValues = { + ...defaultProps, + gitEnabled: true, + gitUrl: 'https://github.com/test/repo.git', + gitUser: 'testuser', + gitCommitMsgTemplate: 'Update ${filename}', + }; + + render(); + + expect( + screen.getByDisplayValue('https://github.com/test/repo.git') + ).toBeInTheDocument(); + expect(screen.getByDisplayValue('testuser')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Update ${filename}')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/workspace/WorkspaceSettings.test.tsx b/app/src/components/settings/workspace/WorkspaceSettings.test.tsx new file mode 100644 index 0000000..71b177a --- /dev/null +++ b/app/src/components/settings/workspace/WorkspaceSettings.test.tsx @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import WorkspaceSettings from './WorkspaceSettings'; +import { Theme } from '@/types/models'; + +const mockUpdateSettings = vi.fn(); +vi.mock('../../../hooks/useWorkspace', () => ({ + useWorkspace: vi.fn(), +})); + +const mockSetSettingsModalVisible = vi.fn(); +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: () => ({ + settingsModalVisible: true, + setSettingsModalVisible: mockSetSettingsModalVisible, + }), +})); + +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +vi.mock('./GeneralSettings', () => ({ + default: ({ + name, + onInputChange, + }: { + name: string; + onInputChange: (key: string, value: string) => void; + }) => ( +
+ onInputChange('name', e.target.value)} + /> +
+ ), +})); + +vi.mock('./AppearanceSettings', () => ({ + default: ({ onThemeChange }: { onThemeChange: (theme: string) => void }) => ( +
+ +
+ ), +})); + +vi.mock('./EditorSettings', () => ({ + default: () =>
Editor Settings
, +})); + +vi.mock('./GitSettings', () => ({ + default: () =>
Git Settings
, +})); + +vi.mock('./DangerZoneSettings', () => ({ + default: () =>
Danger Zone
, +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('WorkspaceSettings', () => { + beforeEach(async () => { + vi.clearAllMocks(); + mockUpdateSettings.mockResolvedValue(undefined); + + const { useWorkspace } = await import('../../../hooks/useWorkspace'); + vi.mocked(useWorkspace).mockReturnValue({ + currentWorkspace: { + name: 'Test Workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', + }, + workspaces: [], + updateSettings: mockUpdateSettings, + loading: false, + colorScheme: 'light', + updateColorScheme: vi.fn(), + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + }); + }); + + it('renders modal with all setting sections', () => { + render(); + + expect(screen.getByText('Workspace Settings')).toBeInTheDocument(); + expect(screen.getByTestId('general-settings')).toBeInTheDocument(); + expect(screen.getByTestId('appearance-settings')).toBeInTheDocument(); + expect(screen.getByTestId('editor-settings')).toBeInTheDocument(); + expect(screen.getByTestId('git-settings')).toBeInTheDocument(); + expect(screen.getByTestId('danger-zone-settings')).toBeInTheDocument(); + }); + + it('shows unsaved changes badge when settings are modified', () => { + render(); + + const nameInput = screen.getByTestId('workspace-name-input'); + fireEvent.change(nameInput, { target: { value: 'Updated Workspace' } }); + + expect(screen.getByText('Unsaved Changes')).toBeInTheDocument(); + }); + + it('saves settings successfully', async () => { + render(); + + const nameInput = screen.getByTestId('workspace-name-input'); + fireEvent.change(nameInput, { target: { value: 'Updated Workspace' } }); + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + expect(saveButton).toBeDefined(); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockUpdateSettings).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Updated Workspace' }) + ); + }); + + await waitFor(() => { + expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false); + }); + }); + + it('handles theme changes', () => { + render(); + + const themeToggle = screen.getByTestId('theme-toggle'); + fireEvent.click(themeToggle); + + expect(screen.getByText('Unsaved Changes')).toBeInTheDocument(); + }); + + it('closes modal when cancel is clicked', () => { + render(); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false); + }); + + it('prevents saving with empty workspace name', async () => { + const { notifications } = await import('@mantine/notifications'); + + render(); + + const nameInput = screen.getByTestId('workspace-name-input'); + fireEvent.change(nameInput, { target: { value: ' ' } }); // Empty/whitespace + + const saveButton = screen.getByRole('button', { name: 'Save Changes' }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(notifications.show).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Workspace name cannot be empty', + color: 'red', + }) + ); + }); + + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/settings/workspace/WorkspaceSettings.tsx b/app/src/components/settings/workspace/WorkspaceSettings.tsx index f20b01c..109f999 100644 --- a/app/src/components/settings/workspace/WorkspaceSettings.tsx +++ b/app/src/components/settings/workspace/WorkspaceSettings.tsx @@ -235,7 +235,7 @@ const WorkspaceSettings: React.FC = () => { - +
diff --git a/app/src/contexts/AuthContext.test.tsx b/app/src/contexts/AuthContext.test.tsx new file mode 100644 index 0000000..460fff5 --- /dev/null +++ b/app/src/contexts/AuthContext.test.tsx @@ -0,0 +1,770 @@ +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', async () => { + (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); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + }); + + it('provides all expected functions', async () => { + (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'); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + }); + + 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 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', async () => { + 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); + + await act(async () => { + resolveGetCurrentUser!(mockUser); + await pendingPromise; + }); + + await waitFor(() => { + expect(result.current.initialized).toBe(true); + }); + }); + + 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 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..d1b9b30 --- /dev/null +++ b/app/src/contexts/ModalContext.test.tsx @@ -0,0 +1,218 @@ +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; +}; + +// Modal field pairs for parameterized testing +const modalFieldPairs = [ + { field: 'newFileModalVisible', setter: 'setNewFileModalVisible' }, + { field: 'deleteFileModalVisible', setter: 'setDeleteFileModalVisible' }, + { + field: 'commitMessageModalVisible', + setter: 'setCommitMessageModalVisible', + }, + { field: 'settingsModalVisible', setter: 'setSettingsModalVisible' }, + { + field: 'switchWorkspaceModalVisible', + setter: 'setSwitchWorkspaceModalVisible', + }, + { + field: 'createWorkspaceModalVisible', + setter: 'setCreateWorkspaceModalVisible', + }, +] as const; + +describe('ModalContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('ModalProvider', () => { + it('provides modal context with initial false values and all setter functions', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // All modal states should be false initially and setters should be functions + modalFieldPairs.forEach(({ field, setter }) => { + expect(result.current[field]).toBe(false); + expect(typeof result.current[setter]).toBe('function'); + }); + }); + + it('maintains function stability across re-renders', () => { + const wrapper = createWrapper(); + const { result, rerender } = renderHook(() => useModalContext(), { + wrapper, + }); + + const initialSetters = modalFieldPairs.map( + ({ setter }) => result.current[setter] + ); + + rerender(); + + modalFieldPairs.forEach(({ setter }, index) => { + expect(result.current[setter]).toBe(initialSetters[index]); + }); + }); + }); + + describe('useModalContext hook', () => { + it('throws error when used outside ModalProvider', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => { + renderHook(() => useModalContext()); + }).toThrow('useModalContext must be used within a ModalProvider'); + + consoleSpy.mockRestore(); + }); + + it('returns complete context interface', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + modalFieldPairs.forEach(({ field, setter }) => { + expect(field in result.current).toBe(true); + expect(setter in result.current).toBe(true); + }); + }); + }); + + describe('modal state management', () => { + // Test all modals with the same pattern using parameterized tests + modalFieldPairs.forEach(({ field, setter }) => { + describe(field, () => { + it('can be toggled true and false', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Set to true + act(() => { + result.current[setter](true); + }); + expect(result.current[field]).toBe(true); + + // Set to false + act(() => { + result.current[setter](false); + }); + expect(result.current[field]).toBe(false); + }); + + it('supports function updater pattern', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Toggle using function updater + act(() => { + result.current[setter]((prev) => !prev); + }); + expect(result.current[field]).toBe(true); + + act(() => { + result.current[setter]((prev) => !prev); + }); + expect(result.current[field]).toBe(false); + }); + }); + }); + + it('each modal state is independent', () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useModalContext(), { wrapper }); + + // Set first three 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 + act(() => { + modalFieldPairs.forEach(({ setter }) => { + result.current[setter](true); + }); + }); + + // Toggle one modal off + act(() => { + result.current.setNewFileModalVisible(false); + }); + + expect(result.current.newFileModalVisible).toBe(false); + // All others should remain true + modalFieldPairs.slice(1).forEach(({ field }) => { + expect(result.current[field]).toBe(true); + }); + }); + + it('supports 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); + }); + }); +}); diff --git a/app/src/contexts/ThemeContext.test.tsx b/app/src/contexts/ThemeContext.test.tsx new file mode 100644 index 0000000..f0615e2 --- /dev/null +++ b/app/src/contexts/ThemeContext.test.tsx @@ -0,0 +1,256 @@ +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 fallback to light scheme', () => { + const wrapper = createWrapper('auto'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + expect(result.current.colorScheme).toBe('light'); + 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 multiple color scheme changes', () => { + const wrapper = createWrapper('light'); + const { result } = renderHook(() => useTheme(), { wrapper }); + + act(() => { + result.current.updateColorScheme('dark'); + }); + + act(() => { + // Should not set color scheme to 'auto' + result.current.updateColorScheme('auto'); + }); + + act(() => { + result.current.updateColorScheme('light'); + }); + + expect(mockSetColorScheme).toHaveBeenCalledTimes(2); + expect(mockSetColorScheme).toHaveBeenNthCalledWith(1, 'dark'); + expect(mockSetColorScheme).toHaveBeenNthCalledWith(2, 'light'); + }); + }); + + describe('context 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'); + }); + 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('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'); + }); + }); +}); diff --git a/app/src/contexts/ThemeContext.tsx b/app/src/contexts/ThemeContext.tsx index 5ccb069..530e726 100644 --- a/app/src/contexts/ThemeContext.tsx +++ b/app/src/contexts/ThemeContext.tsx @@ -22,13 +22,19 @@ export const ThemeProvider: React.FC = ({ children }) => { const updateColorScheme = useCallback( (newTheme: MantineColorScheme): void => { - setColorScheme(newTheme); + if (newTheme === 'light' || newTheme === 'dark') { + if (setColorScheme) { + setColorScheme(newTheme); + } + } }, [setColorScheme] ); + // Ensure colorScheme is never undefined by falling back to light theme const value: ThemeContextType = { - colorScheme, + colorScheme: + colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : 'light', updateColorScheme, }; diff --git a/app/src/contexts/WorkspaceDataContext.test.tsx b/app/src/contexts/WorkspaceDataContext.test.tsx new file mode 100644 index 0000000..0a12efd --- /dev/null +++ b/app/src/contexts/WorkspaceDataContext.test.tsx @@ -0,0 +1,762 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import React from 'react'; +import { + WorkspaceDataProvider, + useWorkspaceData, +} from './WorkspaceDataContext'; +import { + DEFAULT_WORKSPACE_SETTINGS, + type Workspace, + Theme, +} from '@/types/models'; + +// Set up mocks before imports are used +vi.mock('@/api/workspace', () => { + return { + getWorkspace: vi.fn(), + listWorkspaces: vi.fn(), + getLastWorkspaceName: vi.fn(), + updateLastWorkspaceName: vi.fn(), + }; +}); + +vi.mock('@mantine/notifications', () => { + return { + notifications: { + show: vi.fn(), + }, + }; +}); + +vi.mock('./ThemeContext', () => { + return { + useTheme: vi.fn(), + }; +}); + +// Import the mocks after they've been defined +import { + getWorkspace as mockGetWorkspace, + listWorkspaces as mockListWorkspaces, + getLastWorkspaceName as mockGetLastWorkspaceName, + updateLastWorkspaceName as mockUpdateLastWorkspaceName, +} from '@/api/workspace'; +import { notifications } from '@mantine/notifications'; +import { useTheme } from './ThemeContext'; + +// Get reference to the mocked functions +const mockNotificationsShow = notifications.show as unknown as ReturnType< + typeof vi.fn +>; +const mockUseTheme = useTheme as ReturnType; +const mockUpdateColorScheme = vi.fn(); + +// Mock workspace data +const mockWorkspace: Workspace = { + id: 1, + name: 'test-workspace', + theme: Theme.Dark, + createdAt: '2024-01-01T00:00:00Z', + autoSave: true, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', +}; + +const mockWorkspace2: Workspace = { + id: 2, + name: 'workspace-2', + theme: Theme.Light, + createdAt: '2024-01-02T00:00:00Z', + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '', + gitCommitName: '', + gitCommitEmail: '', +}; + +const mockWorkspaceList: Workspace[] = [mockWorkspace, mockWorkspace2]; + +// Helper wrapper component for testing +const createWrapper = () => { + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + Wrapper.displayName = 'WorkspaceDataProviderTestWrapper'; + return Wrapper; +}; + +describe('WorkspaceDataContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Setup default theme mock + mockUseTheme.mockReturnValue({ + colorScheme: 'light', + updateColorScheme: mockUpdateColorScheme, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('WorkspaceDataProvider initialization', () => { + it('initializes with null workspace and loading state', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + expect(result.current.currentWorkspace).toBeNull(); + expect(result.current.loading).toBe(true); + expect(result.current.workspaces).toEqual([]); + expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('provides all expected functions', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + expect(typeof result.current.loadWorkspaces).toBe('function'); + expect(typeof result.current.loadWorkspaceData).toBe('function'); + expect(typeof result.current.setCurrentWorkspace).toBe('function'); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('loads last workspace when available', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + 'test-workspace' + ); + (mockGetWorkspace as ReturnType).mockResolvedValue( + mockWorkspace + ); + (mockListWorkspaces as ReturnType).mockResolvedValue( + mockWorkspaceList + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(result.current.workspaces).toEqual(mockWorkspaceList); + expect(result.current.settings).toEqual(mockWorkspace); + expect(mockGetLastWorkspaceName).toHaveBeenCalledTimes(1); + expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace'); + expect(mockListWorkspaces).toHaveBeenCalledTimes(1); + expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark'); + }); + + it('loads first available workspace when no last workspace', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue( + mockWorkspaceList + ); + (mockGetWorkspace as ReturnType).mockResolvedValue( + mockWorkspace + ); + ( + mockUpdateLastWorkspaceName as ReturnType + ).mockResolvedValue(undefined); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(mockUpdateLastWorkspaceName).toHaveBeenCalledWith( + 'test-workspace' + ); + expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace'); + }); + + it('handles initialization error gracefully', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + (mockGetLastWorkspaceName as ReturnType).mockRejectedValue( + new Error('Network error') + ); + (mockListWorkspaces as ReturnType).mockResolvedValue( + mockWorkspaceList + ); + (mockGetWorkspace as ReturnType).mockResolvedValue( + mockWorkspace + ); + ( + mockUpdateLastWorkspaceName as ReturnType + ).mockResolvedValue(undefined); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to initialize workspace:', + expect.any(Error) + ); + // Should fallback to loading first available workspace + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + + consoleSpy.mockRestore(); + }); + + it('handles case when no workspaces are available', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.currentWorkspace).toBeNull(); + expect(result.current.workspaces).toEqual([]); + expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); + + consoleSpy.mockRestore(); + }); + }); + + describe('useWorkspaceData hook', () => { + it('throws error when used outside WorkspaceDataProvider', () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + expect(() => { + renderHook(() => useWorkspaceData()); + }).toThrow( + 'useWorkspaceData must be used within a WorkspaceDataProvider' + ); + + consoleSpy.mockRestore(); + }); + + it('returns workspace context when used within provider', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current).toBeDefined(); + expect(typeof result.current).toBe('object'); + }); + + it('maintains function stability across re-renders', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result, rerender } = renderHook(() => useWorkspaceData(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const initialFunctions = { + loadWorkspaces: result.current.loadWorkspaces, + loadWorkspaceData: result.current.loadWorkspaceData, + setCurrentWorkspace: result.current.setCurrentWorkspace, + }; + + rerender(); + + expect(result.current.loadWorkspaces).toBe( + initialFunctions.loadWorkspaces + ); + expect(result.current.loadWorkspaceData).toBe( + initialFunctions.loadWorkspaceData + ); + expect(result.current.setCurrentWorkspace).toBe( + initialFunctions.setCurrentWorkspace + ); + }); + }); + + describe('loadWorkspaces functionality', () => { + beforeEach(() => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + }); + + it('loads workspaces successfully', async () => { + (mockListWorkspaces as ReturnType).mockResolvedValue( + mockWorkspaceList + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + let workspaces: Workspace[] | undefined; + await act(async () => { + workspaces = await result.current.loadWorkspaces(); + }); + + expect(workspaces).toEqual(mockWorkspaceList); + expect(result.current.workspaces).toEqual(mockWorkspaceList); + }); + + it('handles loadWorkspaces failure', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + (mockListWorkspaces as ReturnType) + .mockResolvedValueOnce([]) // Initial load + .mockRejectedValueOnce(new Error('Failed to load workspaces')); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + let workspaces: Workspace[] | undefined; + await act(async () => { + workspaces = await result.current.loadWorkspaces(); + }); + + expect(workspaces).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load workspaces:', + expect.any(Error) + ); + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load workspaces list', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('loadWorkspaceData functionality', () => { + beforeEach(() => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + }); + + it('loads workspace data successfully', async () => { + (mockGetWorkspace as ReturnType).mockResolvedValue( + mockWorkspace + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.loadWorkspaceData('test-workspace'); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(result.current.settings).toEqual(mockWorkspace); + expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace'); + expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark'); + }); + + it('handles loadWorkspaceData failure', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + (mockGetWorkspace as ReturnType).mockRejectedValue( + new Error('Workspace not found') + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.loadWorkspaceData('nonexistent-workspace'); + }); + + expect(result.current.currentWorkspace).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load workspace data:', + expect.any(Error) + ); + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load workspace data', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('updates theme when loading workspace', async () => { + const lightThemeWorkspace = { ...mockWorkspace, theme: 'light' }; + (mockGetWorkspace as ReturnType).mockResolvedValue( + lightThemeWorkspace + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + await result.current.loadWorkspaceData('test-workspace'); + }); + + expect(mockUpdateColorScheme).toHaveBeenCalledWith('light'); + }); + }); + + describe('setCurrentWorkspace functionality', () => { + beforeEach(() => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + }); + + it('sets current workspace', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + result.current.setCurrentWorkspace(mockWorkspace); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(result.current.settings).toEqual(mockWorkspace); + }); + + it('sets workspace to null', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Set a workspace first + act(() => { + result.current.setCurrentWorkspace(mockWorkspace); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + + // Then set it to null + act(() => { + result.current.setCurrentWorkspace(null); + }); + + expect(result.current.currentWorkspace).toBeNull(); + expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); + }); + }); + + describe('workspace state transitions', () => { + beforeEach(() => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + }); + + it('transitions from null to workspace', async () => { + (mockGetWorkspace as ReturnType).mockResolvedValue( + mockWorkspace + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.currentWorkspace).toBeNull(); + + await act(async () => { + await result.current.loadWorkspaceData('test-workspace'); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + }); + + it('transitions between different workspaces', async () => { + (mockGetWorkspace as ReturnType) + .mockResolvedValueOnce(mockWorkspace) + .mockResolvedValueOnce(mockWorkspace2); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Load first workspace + await act(async () => { + await result.current.loadWorkspaceData('test-workspace'); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(mockUpdateColorScheme).toHaveBeenCalledWith('dark'); + + // Load second workspace + await act(async () => { + await result.current.loadWorkspaceData('workspace-2'); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace2); + expect(mockUpdateColorScheme).toHaveBeenCalledWith('light'); + }); + }); + + describe('context value structure', () => { + it('provides expected context interface when no workspace loaded', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.currentWorkspace).toBeNull(); + expect(result.current.workspaces).toEqual([]); + expect(result.current.settings).toEqual(DEFAULT_WORKSPACE_SETTINGS); + expect(result.current.loading).toBe(false); + + expect(typeof result.current.loadWorkspaces).toBe('function'); + expect(typeof result.current.loadWorkspaceData).toBe('function'); + expect(typeof result.current.setCurrentWorkspace).toBe('function'); + }); + + it('provides correct context when workspace loaded', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + 'test-workspace' + ); + (mockGetWorkspace as ReturnType).mockResolvedValue( + mockWorkspace + ); + (mockListWorkspaces as ReturnType).mockResolvedValue( + mockWorkspaceList + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(result.current.workspaces).toEqual(mockWorkspaceList); + expect(result.current.settings).toEqual(mockWorkspace); + expect(result.current.loading).toBe(false); + + expect(typeof result.current.loadWorkspaces).toBe('function'); + expect(typeof result.current.loadWorkspaceData).toBe('function'); + expect(typeof result.current.setCurrentWorkspace).toBe('function'); + }); + }); + + describe('loading states', () => { + it('shows loading during initialization', async () => { + let resolveGetLastWorkspaceName: (value: string | null) => void; + const pendingPromise = new Promise((resolve) => { + resolveGetLastWorkspaceName = resolve; + }); + (mockGetLastWorkspaceName as ReturnType).mockReturnValue( + pendingPromise + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + expect(result.current.loading).toBe(true); + + await act(async () => { + resolveGetLastWorkspaceName!(null); + await pendingPromise; + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('clears loading after initialization completes', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + 'test-workspace' + ); + (mockGetWorkspace as ReturnType).mockResolvedValue( + mockWorkspace + ); + (mockListWorkspaces as ReturnType).mockResolvedValue( + mockWorkspaceList + ); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('clears loading after initialization fails', async () => { + (mockGetLastWorkspaceName as ReturnType).mockRejectedValue( + new Error('Init failed') + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + }); + + describe('concurrent operations', () => { + it('handles concurrent loadWorkspaceData calls', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType).mockResolvedValue([]); + (mockGetWorkspace as ReturnType) + .mockResolvedValueOnce(mockWorkspace) + .mockResolvedValueOnce(mockWorkspace2); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Make concurrent calls + await act(async () => { + await Promise.all([ + result.current.loadWorkspaceData('test-workspace'), + result.current.loadWorkspaceData('workspace-2'), + ]); + }); + + expect(mockGetWorkspace).toHaveBeenCalledTimes(2); + expect(mockGetWorkspace).toHaveBeenCalledWith('test-workspace'); + expect(mockGetWorkspace).toHaveBeenCalledWith('workspace-2'); + }); + + it('handles concurrent loadWorkspaces calls', async () => { + (mockGetLastWorkspaceName as ReturnType).mockResolvedValue( + null + ); + (mockListWorkspaces as ReturnType) + .mockResolvedValueOnce([]) // Initial load + .mockResolvedValue(mockWorkspaceList) // Subsequent calls + .mockResolvedValue(mockWorkspaceList); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useWorkspaceData(), { wrapper }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Make concurrent calls + const [result1, result2] = await act(async () => { + return Promise.all([ + result.current.loadWorkspaces(), + result.current.loadWorkspaces(), + ]); + }); + + expect(result1).toEqual(mockWorkspaceList); + expect(result2).toEqual(mockWorkspaceList); + expect(result.current.workspaces).toEqual(mockWorkspaceList); + }); + }); +}); diff --git a/app/src/hooks/useAdminData.test.ts b/app/src/hooks/useAdminData.test.ts new file mode 100644 index 0000000..1d2153c --- /dev/null +++ b/app/src/hooks/useAdminData.test.ts @@ -0,0 +1,577 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useAdminData } from './useAdminData'; +import * as adminApi from '@/api/admin'; +import { + UserRole, + type SystemStats, + type User, + type WorkspaceStats, +} from '@/types/models'; + +// Mock dependencies +vi.mock('@/api/admin'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Import notifications for assertions +import { notifications } from '@mantine/notifications'; + +// Mock data +const mockSystemStats: SystemStats = { + totalUsers: 10, + activeUsers: 8, + totalWorkspaces: 15, + totalFiles: 150, + totalSize: 1024000, +}; + +const mockUsers: User[] = [ + { + id: 1, + email: 'admin@example.com', + displayName: 'Admin User', + role: UserRole.Admin, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }, + { + id: 2, + email: 'editor@example.com', + displayName: 'Editor User', + role: UserRole.Editor, + createdAt: '2024-01-02T00:00:00Z', + lastWorkspaceId: 2, + }, +]; + +const mockWorkspaceStats: WorkspaceStats[] = [ + { + userID: 1, + userEmail: 'admin@example.com', + workspaceID: 1, + workspaceName: 'admin-workspace', + workspaceCreatedAt: '2024-01-01T00:00:00Z', + fileCountStats: { + totalFiles: 10, + totalSize: 204800, + }, + }, + { + userID: 2, + userEmail: 'editor@example.com', + workspaceID: 2, + workspaceName: 'editor-workspace', + workspaceCreatedAt: '2024-01-02T00:00:00Z', + fileCountStats: { + totalFiles: 15, + totalSize: 307200, + }, + }, +]; + +describe('useAdminData', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('stats data type', () => { + it('initializes with empty stats and loading state', async () => { + const { result } = renderHook(() => useAdminData('stats')); + + expect(result.current.data).toEqual({}); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + expect(typeof result.current.reload).toBe('function'); + + // Wait for the hook to complete its async initialization + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('loads system stats successfully', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockResolvedValue(mockSystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockSystemStats); + expect(result.current.error).toBeNull(); + expect(mockGetSystemStats).toHaveBeenCalledTimes(1); + }); + + it('handles stats loading errors', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockRejectedValue(new Error('Failed to load stats')); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual({}); + expect(result.current.error).toBe('Failed to load stats'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load stats: Failed to load stats', + color: 'red', + }); + }); + + it('reloads stats data', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockResolvedValue(mockSystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockGetSystemStats).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.reload(); + }); + + expect(mockGetSystemStats).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual(mockSystemStats); + }); + }); + + describe('users data type', () => { + it('initializes with empty users array and loading state', async () => { + const { result } = renderHook(() => useAdminData('users')); + + expect(result.current.data).toEqual([]); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + expect(typeof result.current.reload).toBe('function'); + + // Wait for the hook to complete its async initialization + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('loads users successfully', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockResolvedValue(mockUsers); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockUsers); + expect(result.current.error).toBeNull(); + expect(mockGetUsers).toHaveBeenCalledTimes(1); + }); + + it('handles users loading errors', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockRejectedValue(new Error('Failed to load users')); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual([]); + expect(result.current.error).toBe('Failed to load users'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load users: Failed to load users', + color: 'red', + }); + }); + + it('reloads users data', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockResolvedValue(mockUsers); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockGetUsers).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.reload(); + }); + + expect(mockGetUsers).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual(mockUsers); + }); + + it('handles empty users array', async () => { + const mockGetUsers = vi.mocked(adminApi.getUsers); + mockGetUsers.mockResolvedValue([]); + + const { result } = renderHook(() => useAdminData('users')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual([]); + expect(result.current.error).toBeNull(); + }); + }); + + describe('workspaces data type', () => { + it('initializes with empty workspaces array and loading state', async () => { + const { result } = renderHook(() => useAdminData('workspaces')); + + expect(result.current.data).toEqual([]); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + expect(typeof result.current.reload).toBe('function'); + + // Wait for the hook to complete its async initialization + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + + it('loads workspaces successfully', async () => { + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockWorkspaceStats); + expect(result.current.error).toBeNull(); + expect(mockGetWorkspaces).toHaveBeenCalledTimes(1); + }); + + it('handles workspaces loading errors', async () => { + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockRejectedValue( + new Error('Failed to load workspaces') + ); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual([]); + expect(result.current.error).toBe('Failed to load workspaces'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load workspaces: Failed to load workspaces', + color: 'red', + }); + }); + + it('reloads workspaces data', async () => { + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockGetWorkspaces).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.reload(); + }); + + expect(mockGetWorkspaces).toHaveBeenCalledTimes(2); + expect(result.current.data).toEqual(mockWorkspaceStats); + }); + + it('handles workspaces with minimal configuration', async () => { + const minimalWorkspaceStats: WorkspaceStats[] = [ + { + userID: 3, + userEmail: 'minimal@example.com', + workspaceID: 3, + workspaceName: 'minimal-workspace', + workspaceCreatedAt: '2024-01-03T00:00:00Z', + }, + ]; + + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + mockGetWorkspaces.mockResolvedValue(minimalWorkspaceStats); + + const { result } = renderHook(() => useAdminData('workspaces')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(minimalWorkspaceStats); + expect(result.current.error).toBeNull(); + }); + }); + + describe('error handling', () => { + it('handles API errors with error response object', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + // Create a properly typed error object to simulate API error response + const errorWithResponse = new Error('Request failed'); + type ErrorWithResponse = Error & { + response: { + data: { + error: string; + }; + }; + }; + (errorWithResponse as ErrorWithResponse).response = { + data: { + error: 'Custom API error message', + }, + }; + mockGetSystemStats.mockRejectedValue(errorWithResponse); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.error).toBe('Custom API error message'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to load stats: Custom API error message', + color: 'red', + }); + }); + + it('clears error on successful reload', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats + .mockRejectedValueOnce(new Error('Initial error')) + .mockResolvedValueOnce(mockSystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + // Wait for initial error + await waitFor(() => { + expect(result.current.error).toBe('Initial error'); + }); + + // Reload successfully + await act(async () => { + await result.current.reload(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + expect(result.current.data).toEqual(mockSystemStats); + }); + }); + }); + + describe('loading state management', () => { + it('manages loading state correctly through full lifecycle', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + let resolvePromise: (value: SystemStats) => void; + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve; + }); + mockGetSystemStats.mockReturnValue(pendingPromise); + + const { result } = renderHook(() => useAdminData('stats')); + + // Initial load should be loading + expect(result.current.loading).toBe(true); + + // Resolve initial load + await act(async () => { + resolvePromise!(mockSystemStats); + await pendingPromise; + }); + + expect(result.current.loading).toBe(false); + + // Test reload loading state + let resolveReload: (value: SystemStats) => void; + const reloadPromise = new Promise((resolve) => { + resolveReload = resolve; + }); + mockGetSystemStats.mockReturnValueOnce(reloadPromise); + + act(() => { + void result.current.reload(); + }); + + expect(result.current.loading).toBe(true); + + await act(async () => { + resolveReload!(mockSystemStats); + await reloadPromise; + }); + + expect(result.current.loading).toBe(false); + }); + }); + + describe('data consistency', () => { + it('handles data type parameter changes', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + const mockGetUsers = vi.mocked(adminApi.getUsers); + + mockGetSystemStats.mockResolvedValue(mockSystemStats); + mockGetUsers.mockResolvedValue(mockUsers); + + const { result, rerender } = renderHook( + ({ type }) => useAdminData(type), + { + initialProps: { type: 'stats' as const } as { + type: 'stats' | 'users' | 'workspaces'; + }, + } + ); + + // Wait for stats to load + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockSystemStats); + + // Change to users type + rerender({ type: 'users' as const }); + + // Should reset to loading and empty array for users + expect(result.current.loading).toBe(true); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.data).toEqual(mockUsers); + expect(mockGetUsers).toHaveBeenCalledTimes(1); + }); + it('handles data type changes correctly with different initial values', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + const mockGetUsers = vi.mocked(adminApi.getUsers); + const mockGetWorkspaces = vi.mocked(adminApi.getWorkspaces); + + mockGetSystemStats.mockResolvedValue(mockSystemStats); + mockGetUsers.mockResolvedValue(mockUsers); + mockGetWorkspaces.mockResolvedValue(mockWorkspaceStats); + + const { result, rerender } = renderHook( + ({ type }) => useAdminData(type), + { + initialProps: { type: 'stats' as const } as { + type: 'stats' | 'users' | 'workspaces'; + }, + } + ); + + // Wait for stats to load + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.data).toEqual(mockSystemStats); + + // Change to users type - should reset to empty array and reload + act(() => { + rerender({ type: 'users' as const }); + }); + + // Data should reset to empty array immediately when type changes + expect(result.current.data).toEqual([]); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.data).toEqual(mockUsers); + + // Change to workspaces type - should reset to empty array and reload + act(() => { + rerender({ type: 'workspaces' as const }); + }); + + // Data should reset to empty array immediately when type changes + expect(result.current.data).toEqual([]); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + expect(result.current.data).toEqual(mockWorkspaceStats); + + // Verify correct API calls were made + expect(mockGetSystemStats).toHaveBeenCalledTimes(1); + expect(mockGetUsers).toHaveBeenCalledTimes(1); + expect(mockGetWorkspaces).toHaveBeenCalledTimes(1); + }); + }); + + describe('function stability', () => { + it('maintains stable reload function reference', async () => { + const { result, rerender } = renderHook(() => useAdminData('stats')); + + const initialReload = result.current.reload; + + rerender(); + + expect(result.current.reload).toBe(initialReload); + + // Wait for the hook to complete its async initialization + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + }); + }); + + describe('concurrent operations', () => { + it('handles multiple concurrent reloads', async () => { + const mockGetSystemStats = vi.mocked(adminApi.getSystemStats); + mockGetSystemStats.mockResolvedValue(mockSystemStats); + + const { result } = renderHook(() => useAdminData('stats')); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + // Trigger multiple reloads + await act(async () => { + await Promise.all([ + result.current.reload(), + result.current.reload(), + result.current.reload(), + ]); + }); + + expect(mockGetSystemStats).toHaveBeenCalledTimes(4); // 1 initial + 3 reloads + expect(result.current.data).toEqual(mockSystemStats); + expect(result.current.loading).toBe(false); + }); + }); +}); diff --git a/app/src/hooks/useAdminData.ts b/app/src/hooks/useAdminData.ts index 730b4ad..519aa9a 100644 --- a/app/src/hooks/useAdminData.ts +++ b/app/src/hooks/useAdminData.ts @@ -28,7 +28,7 @@ export const useAdminData = ( type: T ): AdminDataResult => { // Initialize with the appropriate empty type - const getInitialData = (): AdminData => { + const getInitialData = useCallback((): AdminData => { if (type === 'stats') { return {} as SystemStats as AdminData; } else if (type === 'workspaces') { @@ -38,12 +38,18 @@ export const useAdminData = ( } else { return [] as unknown as AdminData; } - }; + }, [type]); const [data, setData] = useState>(getInitialData()); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Reset data when type changes + useEffect(() => { + setData(getInitialData()); + setError(null); + }, [type, getInitialData]); + const loadData = useCallback(async () => { setLoading(true); setError(null); diff --git a/app/src/hooks/useFileContent.test.ts b/app/src/hooks/useFileContent.test.ts new file mode 100644 index 0000000..fade57c --- /dev/null +++ b/app/src/hooks/useFileContent.test.ts @@ -0,0 +1,509 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useFileContent } from './useFileContent'; +import * as fileApi from '@/api/file'; +import * as fileHelpers from '@/utils/fileHelpers'; +import { DEFAULT_FILE } from '@/types/models'; + +// Mock dependencies +vi.mock('@/api/file'); +vi.mock('@/utils/fileHelpers'); + +// Create a mock workspace context hook +const mockWorkspaceData: { + currentWorkspace: { id: number; name: string } | null; +} = { + currentWorkspace: { + id: 1, + name: 'test-workspace', + }, +}; + +vi.mock('../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => mockWorkspaceData, +})); + +describe('useFileContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset workspace data + mockWorkspaceData.currentWorkspace = { + id: 1, + name: 'test-workspace', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('returns default content and no unsaved changes initially', () => { + const { result } = renderHook(() => useFileContent(null)); + + expect(result.current.content).toBe(DEFAULT_FILE.content); + expect(result.current.hasUnsavedChanges).toBe(false); + }); + + it('provides setters for content and unsaved changes', () => { + const { result } = renderHook(() => useFileContent(null)); + + expect(typeof result.current.setContent).toBe('function'); + expect(typeof result.current.setHasUnsavedChanges).toBe('function'); + expect(typeof result.current.loadFileContent).toBe('function'); + expect(typeof result.current.handleContentChange).toBe('function'); + }); + }); + + describe('loading file content', () => { + it('loads default file content when selectedFile is DEFAULT_FILE.path', async () => { + const { result } = renderHook(() => useFileContent(DEFAULT_FILE.path)); + + await waitFor(() => { + expect(result.current.content).toBe(DEFAULT_FILE.content); + expect(result.current.hasUnsavedChanges).toBe(false); + }); + + expect(fileApi.getFileContent).not.toHaveBeenCalled(); + }); + + it('loads file content from API for regular files', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + mockGetFileContent.mockResolvedValue('# Test Content'); + mockIsImageFile.mockReturnValue(false); + + const { result } = renderHook(() => useFileContent('test.md')); + + await waitFor(() => { + expect(result.current.content).toBe('# Test Content'); + }); + + expect(result.current.hasUnsavedChanges).toBe(false); + expect(mockGetFileContent).toHaveBeenCalledWith( + 'test-workspace', + 'test.md' + ); + }); + + it('sets empty content for image files', async () => { + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + mockIsImageFile.mockReturnValue(true); + + const { result } = renderHook(() => useFileContent('image.png')); + + await waitFor(() => { + expect(result.current.content).toBe(''); + expect(result.current.hasUnsavedChanges).toBe(false); + }); + + expect(fileApi.getFileContent).not.toHaveBeenCalled(); + }); + + it('handles API errors gracefully', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockGetFileContent.mockRejectedValue(new Error('API Error')); + mockIsImageFile.mockReturnValue(false); + + const { result } = renderHook(() => useFileContent('error.md')); + + await waitFor(() => { + expect(result.current.content).toBe(''); + expect(result.current.hasUnsavedChanges).toBe(false); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error loading file content:', + expect.any(Error) + ); + consoleSpy.mockRestore(); + }); + + it('does not load content when no workspace is available', () => { + // Mock no workspace + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useFileContent('test.md')); + + expect(result.current.content).toBe(DEFAULT_FILE.content); + expect(fileApi.getFileContent).not.toHaveBeenCalled(); + }); + }); + + describe('content changes', () => { + it('updates content and tracks unsaved changes', () => { + const { result } = renderHook(() => useFileContent(null)); + + act(() => { + result.current.handleContentChange('New content'); + }); + + expect(result.current.content).toBe('New content'); + expect(result.current.hasUnsavedChanges).toBe(true); + }); + + it('does not mark as unsaved when content matches original', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + mockGetFileContent.mockResolvedValue('Original content'); + mockIsImageFile.mockReturnValue(false); + + const { result } = renderHook(() => useFileContent('test.md')); + + // Wait for initial load + await waitFor(() => { + expect(result.current.content).toBe('Original content'); + }); + + // Change content + act(() => { + result.current.handleContentChange('Modified content'); + }); + + expect(result.current.hasUnsavedChanges).toBe(true); + + // Change back to original + act(() => { + result.current.handleContentChange('Original content'); + }); + + expect(result.current.hasUnsavedChanges).toBe(false); + }); + + it('allows direct content setting', () => { + const { result } = renderHook(() => useFileContent(null)); + + act(() => { + result.current.setContent('Direct content'); + }); + + expect(result.current.content).toBe('Direct content'); + // Note: setContent doesn't automatically update unsaved changes + expect(result.current.hasUnsavedChanges).toBe(false); + }); + }); + + describe('file changes', () => { + it('reloads content when selectedFile changes', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + mockGetFileContent + .mockResolvedValueOnce('First file content') + .mockResolvedValueOnce('Second file content'); + mockIsImageFile.mockReturnValue(false); + + const { result, rerender } = renderHook( + ({ selectedFile }) => useFileContent(selectedFile), + { initialProps: { selectedFile: 'first.md' } } + ); + + // Wait for first file to load + await waitFor(() => { + expect(result.current.content).toBe('First file content'); + }); + + // Change to second file + rerender({ selectedFile: 'second.md' }); + + await waitFor(() => { + expect(result.current.content).toBe('Second file content'); + }); + + expect(result.current.hasUnsavedChanges).toBe(false); + expect(mockGetFileContent).toHaveBeenCalledTimes(2); + expect(mockGetFileContent).toHaveBeenNthCalledWith( + 1, + 'test-workspace', + 'first.md' + ); + expect(mockGetFileContent).toHaveBeenNthCalledWith( + 2, + 'test-workspace', + 'second.md' + ); + }); + + it('resets unsaved changes when file changes', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + mockGetFileContent + .mockResolvedValueOnce('File content') + .mockResolvedValueOnce('Other file content'); + mockIsImageFile.mockReturnValue(false); + + const { result, rerender } = renderHook( + ({ selectedFile }) => useFileContent(selectedFile), + { initialProps: { selectedFile: 'first.md' } } + ); + + // Wait for initial load and make changes + await waitFor(() => { + expect(result.current.content).toBe('File content'); + }); + + act(() => { + result.current.handleContentChange('Modified content'); + }); + + expect(result.current.hasUnsavedChanges).toBe(true); + + // Change file + rerender({ selectedFile: 'second.md' }); + + await waitFor(() => { + expect(result.current.hasUnsavedChanges).toBe(false); + }); + }); + + it('does not reload when selectedFile is null', () => { + const { result } = renderHook(() => useFileContent(null)); + + expect(result.current.content).toBe(DEFAULT_FILE.content); + expect(fileApi.getFileContent).not.toHaveBeenCalled(); + }); + }); + + describe('manual loadFileContent', () => { + it('can manually load file content', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + mockGetFileContent.mockResolvedValue('Manually loaded content'); + mockIsImageFile.mockReturnValue(false); + + const { result } = renderHook(() => useFileContent(null)); + + await act(async () => { + await result.current.loadFileContent('manual.md'); + }); + + expect(result.current.content).toBe('Manually loaded content'); + expect(result.current.hasUnsavedChanges).toBe(false); + expect(mockGetFileContent).toHaveBeenCalledWith( + 'test-workspace', + 'manual.md' + ); + }); + + it('handles manual load errors', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockGetFileContent.mockRejectedValue(new Error('Manual load error')); + mockIsImageFile.mockReturnValue(false); + + const { result } = renderHook(() => useFileContent(null)); + + await act(async () => { + await result.current.loadFileContent('error.md'); + }); + + expect(result.current.content).toBe(''); + expect(result.current.hasUnsavedChanges).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error loading file content:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('workspace dependency changes', () => { + it('reloads content when workspace changes while file is selected', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + mockGetFileContent + .mockResolvedValueOnce('Content from workspace 1') + .mockResolvedValueOnce('Content from workspace 2'); + mockIsImageFile.mockReturnValue(false); + + const { result, rerender } = renderHook(() => useFileContent('test.md')); + + // Wait for initial load from workspace 1 + await waitFor(() => { + expect(result.current.content).toBe('Content from workspace 1'); + }); + + // Change workspace + mockWorkspaceData.currentWorkspace = { + id: 2, + name: 'different-workspace', + }; + + rerender(); + + // Should reload content from new workspace + await waitFor(() => { + expect(result.current.content).toBe('Content from workspace 2'); + }); + + expect(mockGetFileContent).toHaveBeenCalledWith( + 'test-workspace', + 'test.md' + ); + expect(mockGetFileContent).toHaveBeenCalledWith( + 'different-workspace', + 'test.md' + ); + expect(result.current.hasUnsavedChanges).toBe(false); + }); + + it('clears content when workspace becomes null', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + mockGetFileContent.mockResolvedValue('Initial content'); + mockIsImageFile.mockReturnValue(false); + + const { result, rerender } = renderHook(() => useFileContent('test.md')); + + // Wait for initial load + await waitFor(() => { + expect(result.current.content).toBe('Initial content'); + }); + + expect(mockGetFileContent).toHaveBeenCalledTimes(1); + vi.clearAllMocks(); // Clear previous calls + + // Remove workspace + mockWorkspaceData.currentWorkspace = null; + rerender(); + + // Content should remain the same (no clearing happens when workspace becomes null) + // The hook keeps the current content and just prevents new loads + expect(result.current.content).toBe('Initial content'); + expect(result.current.hasUnsavedChanges).toBe(false); + expect(mockGetFileContent).not.toHaveBeenCalled(); // No new API calls + }); + }); + + describe('edge cases', () => { + it('handles empty string selectedFile', () => { + const { result } = renderHook(() => useFileContent('')); + + // Empty string should not trigger file loading + expect(result.current.content).toBe(DEFAULT_FILE.content); + expect(fileApi.getFileContent).not.toHaveBeenCalled(); + }); + + it('handles rapid file changes', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + // Set up different responses for each file + mockGetFileContent + .mockImplementationOnce(() => Promise.resolve('Content 1')) + .mockImplementationOnce(() => Promise.resolve('Content 2')) + .mockImplementationOnce(() => Promise.resolve('Content 3')); + mockIsImageFile.mockReturnValue(false); + + const { result, rerender } = renderHook( + ({ selectedFile }) => useFileContent(selectedFile), + { initialProps: { selectedFile: 'file1.md' } } + ); + + // Wait for initial load + await waitFor(() => { + expect(result.current.content).toBe('Content 1'); + }); + + // Rapidly change files + rerender({ selectedFile: 'file2.md' }); + + await waitFor(() => { + expect(result.current.content).toBe('Content 2'); + }); + + rerender({ selectedFile: 'file3.md' }); + + await waitFor(() => { + expect(result.current.content).toBe('Content 3'); + }); + + expect(mockGetFileContent).toHaveBeenCalledTimes(3); + }); + }); + + describe('function stability', () => { + it('maintains stable function references across re-renders and workspace changes', async () => { + const mockGetFileContent = vi.mocked(fileApi.getFileContent); + const mockIsImageFile = vi.mocked(fileHelpers.isImageFile); + + // Mock API calls for both workspaces + mockGetFileContent + .mockResolvedValueOnce('Content from workspace 1') + .mockResolvedValueOnce('Content from workspace 2'); + mockIsImageFile.mockReturnValue(false); + + const { result, rerender } = renderHook(() => useFileContent('test.md')); + + // Wait for initial load to complete + await waitFor(() => { + expect(result.current.content).toBe('Content from workspace 1'); + }); + + const initialFunctions = { + setContent: result.current.setContent, + setHasUnsavedChanges: result.current.setHasUnsavedChanges, + loadFileContent: result.current.loadFileContent, + handleContentChange: result.current.handleContentChange, + }; + + // Re-render with different file + rerender(); + + expect(result.current.setContent).toBe(initialFunctions.setContent); + expect(result.current.setHasUnsavedChanges).toBe( + initialFunctions.setHasUnsavedChanges + ); + expect(result.current.loadFileContent).toBe( + initialFunctions.loadFileContent + ); + expect(result.current.handleContentChange).toBe( + initialFunctions.handleContentChange + ); + + // Change workspace + act(() => { + mockWorkspaceData.currentWorkspace = { + id: 2, + name: 'different-workspace', + }; + }); + + rerender(); + + // Wait for content to load from new workspace + await waitFor(() => { + expect(result.current.content).toBe('Content from workspace 2'); + }); + + // Functions should still be stable (except handleContentChange which depends on originalContent) + expect(result.current.setContent).toBe(initialFunctions.setContent); + expect(result.current.setHasUnsavedChanges).toBe( + initialFunctions.setHasUnsavedChanges + ); + expect(result.current.loadFileContent).not.toBe( + initialFunctions.loadFileContent + ); + // handleContentChange depends on originalContent which changes when workspace changes + expect(result.current.handleContentChange).not.toBe( + initialFunctions.handleContentChange + ); + }); + }); +}); diff --git a/app/src/hooks/useFileList.test.ts b/app/src/hooks/useFileList.test.ts new file mode 100644 index 0000000..5ca17cd --- /dev/null +++ b/app/src/hooks/useFileList.test.ts @@ -0,0 +1,499 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useFileList } from './useFileList'; +import * as fileApi from '@/api/file'; +import type { FileNode } from '@/types/models'; + +// Mock dependencies +vi.mock('@/api/file'); + +// Mock workspace context +const mockWorkspaceData: { + currentWorkspace: { id: number; name: string } | null; + loading: boolean; +} = { + currentWorkspace: { + id: 1, + name: 'test-workspace', + }, + loading: false, +}; + +vi.mock('../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => mockWorkspaceData, +})); + +// Mock file data +const mockFiles: FileNode[] = [ + { + id: '1', + name: 'README.md', + path: 'README.md', + }, + { + id: '2', + name: 'docs', + path: 'docs', + children: [ + { + id: '3', + name: 'guide.md', + path: 'docs/guide.md', + }, + ], + }, + { + id: '4', + name: 'notes.md', + path: 'notes.md', + }, +]; + +describe('useFileList', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset workspace data to defaults + mockWorkspaceData.currentWorkspace = { + id: 1, + name: 'test-workspace', + }; + mockWorkspaceData.loading = false; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('starts with empty files array', () => { + const { result } = renderHook(() => useFileList()); + + expect(result.current.files).toEqual([]); + expect(typeof result.current.loadFileList).toBe('function'); + }); + + it('provides loadFileList function', () => { + const { result } = renderHook(() => useFileList()); + + expect(typeof result.current.loadFileList).toBe('function'); + }); + }); + + describe('loadFileList', () => { + it('loads files successfully', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue(mockFiles); + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(mockFiles); + expect(mockListFiles).toHaveBeenCalledWith('test-workspace'); + }); + + it('handles empty file list', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue([]); + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual([]); + expect(mockListFiles).toHaveBeenCalledWith('test-workspace'); + }); + + it('handles API errors gracefully', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockListFiles.mockRejectedValue(new Error('API Error')); + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual([]); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load file list:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + + it('does not load when no workspace is available', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual([]); + expect(fileApi.listFiles).not.toHaveBeenCalled(); + }); + + it('does not load when workspace is loading', async () => { + mockWorkspaceData.loading = true; + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual([]); + expect(fileApi.listFiles).not.toHaveBeenCalled(); + }); + + it('can be called multiple times', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles + .mockResolvedValueOnce(mockFiles[0] ? [mockFiles[0]] : []) + .mockResolvedValueOnce(mockFiles); + + const { result } = renderHook(() => useFileList()); + + // First call + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual([mockFiles[0]]); + + // Second call + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(mockFiles); + expect(mockListFiles).toHaveBeenCalledTimes(2); + }); + + it('handles concurrent calls gracefully', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue(mockFiles); + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + // Make multiple concurrent calls + await Promise.all([ + result.current.loadFileList(), + result.current.loadFileList(), + result.current.loadFileList(), + ]); + }); + + expect(result.current.files).toEqual(mockFiles); + expect(mockListFiles).toHaveBeenCalledTimes(3); + }); + }); + + describe('workspace dependency', () => { + it('uses correct workspace name for API calls', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue(mockFiles); + + const { result, rerender } = renderHook(() => useFileList()); + + // Load with initial workspace + await act(async () => { + await result.current.loadFileList(); + }); + + expect(mockListFiles).toHaveBeenCalledWith('test-workspace'); + + // Change workspace + mockWorkspaceData.currentWorkspace = { + id: 2, + name: 'different-workspace', + }; + + rerender(); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(mockListFiles).toHaveBeenCalledWith('different-workspace'); + }); + + it('handles workspace becoming null after successful load', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue(mockFiles); + + const { result, rerender } = renderHook(() => useFileList()); + + // Load files with workspace + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(mockFiles); + + // Remove workspace + mockWorkspaceData.currentWorkspace = null; + rerender(); + + // Try to load again + await act(async () => { + await result.current.loadFileList(); + }); + + // Files should remain from previous load, but no new API call + expect(result.current.files).toEqual(mockFiles); + expect(mockListFiles).toHaveBeenCalledTimes(1); + }); + + it('handles workspace loading state changes', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue(mockFiles); + + const { result, rerender } = renderHook(() => useFileList()); + + // Start with loading workspace + mockWorkspaceData.loading = true; + rerender(); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual([]); + expect(mockListFiles).not.toHaveBeenCalled(); + + // Workspace finishes loading + mockWorkspaceData.loading = false; + rerender(); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(mockFiles); + expect(mockListFiles).toHaveBeenCalledWith('test-workspace'); + }); + }); + + describe('file data handling', () => { + it('handles complex file tree structure', async () => { + const complexFiles: FileNode[] = [ + { + id: '1', + name: 'root.md', + path: 'root.md', + }, + { + id: '2', + name: 'folder1', + path: 'folder1', + children: [ + { + id: '3', + name: 'subfolder', + path: 'folder1/subfolder', + children: [ + { + id: '4', + name: 'deep.md', + path: 'folder1/subfolder/deep.md', + }, + ], + }, + { + id: '5', + name: 'file1.md', + path: 'folder1/file1.md', + }, + ], + }, + ]; + + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue(complexFiles); + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(complexFiles); + }); + + it('handles large file lists efficiently', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + + // Create a large file list + const largeFileList: FileNode[] = Array.from( + { length: 1000 }, + (_, i) => ({ + id: `file-${i}`, + name: `file-${i}.md`, + path: `folder/file-${i}.md`, + }) + ); + + mockListFiles.mockResolvedValue(largeFileList); + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(largeFileList); + expect(result.current.files).toHaveLength(1000); + }); + + it('handles files with special characters', async () => { + const specialFiles: FileNode[] = [ + { + id: '1', + name: 'file with spaces.md', + path: 'file with spaces.md', + }, + { + id: '2', + name: 'special-chars_123.md', + path: 'special-chars_123.md', + }, + { + id: '3', + name: 'unicode-文档.md', + path: 'unicode-文档.md', + }, + ]; + + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue(specialFiles); + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(specialFiles); + }); + + it('handles files without children property', async () => { + const filesWithoutChildren: FileNode[] = [ + { + id: '1', + name: 'simple.md', + path: 'simple.md', + }, + { + id: '2', + name: 'another.md', + path: 'another.md', + }, + ]; + + const mockListFiles = vi.mocked(fileApi.listFiles); + mockListFiles.mockResolvedValue(filesWithoutChildren); + + const { result } = renderHook(() => useFileList()); + + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(filesWithoutChildren); + }); + }); + + describe('hook interface stability', () => { + it('loadFileList function is stable across re-renders', () => { + const { result, rerender } = renderHook(() => useFileList()); + + const initialLoadFunction = result.current.loadFileList; + + rerender(); + + expect(result.current.loadFileList).toBe(initialLoadFunction); + }); + + it('returns consistent interface', () => { + const { result } = renderHook(() => useFileList()); + + expect(Array.isArray(result.current.files)).toBe(true); + expect(typeof result.current.loadFileList).toBe('function'); + }); + }); + + describe('error recovery', () => { + it('recovers from API errors on subsequent calls', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // First call fails + mockListFiles.mockRejectedValueOnce(new Error('First error')); + // Second call succeeds + mockListFiles.mockResolvedValueOnce(mockFiles); + + const { result } = renderHook(() => useFileList()); + + // First call - should fail + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual([]); + + // Second call - should succeed + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(mockFiles); + + consoleSpy.mockRestore(); + }); + + it('maintains previous data after error', async () => { + const mockListFiles = vi.mocked(fileApi.listFiles); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // First call succeeds + mockListFiles.mockResolvedValueOnce(mockFiles); + // Second call fails + mockListFiles.mockRejectedValueOnce(new Error('Second error')); + + const { result } = renderHook(() => useFileList()); + + // First call - should succeed + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual(mockFiles); + + // Second call - should fail but maintain previous data + await act(async () => { + await result.current.loadFileList(); + }); + + expect(result.current.files).toEqual([]); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/app/src/hooks/useFileNavigation.test.ts b/app/src/hooks/useFileNavigation.test.ts new file mode 100644 index 0000000..ab177be --- /dev/null +++ b/app/src/hooks/useFileNavigation.test.ts @@ -0,0 +1,472 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useFileNavigation } from './useFileNavigation'; +import { DEFAULT_FILE } from '@/types/models'; + +// Mock dependencies +const mockWorkspaceData: { + currentWorkspace: { id: number; name: string } | null; +} = { + currentWorkspace: { + id: 1, + name: 'test-workspace', + }, +}; + +const mockLastOpenedFile = { + loadLastOpenedFile: vi.fn(), + saveLastOpenedFile: vi.fn(), +}; + +vi.mock('../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => mockWorkspaceData, +})); + +vi.mock('./useLastOpenedFile', () => ({ + useLastOpenedFile: () => mockLastOpenedFile, +})); + +describe('useFileNavigation', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset workspace data to defaults + mockWorkspaceData.currentWorkspace = { + id: 1, + name: 'test-workspace', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('starts with default file selected', () => { + const { result } = renderHook(() => useFileNavigation()); + + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + expect(typeof result.current.handleFileSelect).toBe('function'); + }); + + it('loads last opened file on mount when available', async () => { + mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue( + 'documents/readme.md' + ); + + const { result } = renderHook(() => useFileNavigation()); + + await waitFor(() => { + expect(result.current.selectedFile).toBe('documents/readme.md'); + expect(result.current.isNewFile).toBe(false); + }); + + expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled(); + expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled(); + }); + + it('stays with default file when no last opened file exists', async () => { + mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(null); + + const { result } = renderHook(() => useFileNavigation()); + + await waitFor(() => { + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + }); + + expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled(); + expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled(); + }); + }); + + describe('handleFileSelect', () => { + it('selects a regular file correctly', async () => { + const { result } = renderHook(() => useFileNavigation()); + + await act(async () => { + await result.current.handleFileSelect('notes/todo.md'); + }); + + await waitFor(() => { + expect(result.current.selectedFile).toBe('notes/todo.md'); + expect(result.current.isNewFile).toBe(false); + }); + + expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith( + 'notes/todo.md' + ); + }); + + it('handles null file selection (defaults to default file)', async () => { + const { result } = renderHook(() => useFileNavigation()); + + await act(async () => { + await result.current.handleFileSelect(null); + }); + + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled(); + }); + + it('handles empty string file selection with default file', async () => { + const { result } = renderHook(() => useFileNavigation()); + + await act(async () => { + await result.current.handleFileSelect(''); + }); + + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled(); + }); + + it('preserves current selection when passed empty string with existing selection', async () => { + const { result } = renderHook(() => useFileNavigation()); + + // First select a valid file + await act(async () => { + await result.current.handleFileSelect('existing-file.md'); + }); + + await waitFor(() => { + expect(result.current.selectedFile).toBe('existing-file.md'); + expect(result.current.isNewFile).toBe(false); + }); + + vi.clearAllMocks(); + + // Now send empty string + await act(async () => { + await result.current.handleFileSelect(''); + }); + + // Selection should be preserved + expect(result.current.selectedFile).toBe('existing-file.md'); + expect(result.current.isNewFile).toBe(false); + expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled(); + }); + + it('handles different file path formats', async () => { + const { result } = renderHook(() => useFileNavigation()); + + const testCases = [ + 'simple.md', + 'folder/file.md', + 'deep/nested/path/document.md', + 'file with spaces.md', + 'special-chars_123.md', + 'unicode-文档.md', + ]; + + for (const filePath of testCases) { + await act(async () => { + await result.current.handleFileSelect(filePath); + }); + + await waitFor(() => { + expect(result.current.selectedFile).toBe(filePath); + expect(result.current.isNewFile).toBe(false); + }); + + expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith( + filePath + ); + } + + expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledTimes( + testCases.length + ); + }); + + it('handles rapid file selections', async () => { + const { result } = renderHook(() => useFileNavigation()); + + const files = ['file1.md', 'file2.md', 'file3.md']; + + // Use sequential state updates instead of Promise.all for more predictable results + for (const file of files) { + await act(async () => { + await result.current.handleFileSelect(file); + }); + } + + // After all updates, we should have the last file selected + await waitFor(() => { + expect(result.current.selectedFile).toBe(files[files.length - 1]); + expect(result.current.isNewFile).toBe(false); + }); + + expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledTimes( + files.length + ); + }); + + it('handles file selection errors gracefully', async () => { + mockLastOpenedFile.saveLastOpenedFile.mockRejectedValue( + new Error('Save failed') + ); + + const { result } = renderHook(() => useFileNavigation()); + + // Should not throw + await act(async () => { + await result.current.handleFileSelect('error-file.md'); + }); + + // Wait for state update despite the error + await waitFor(() => { + expect(result.current.selectedFile).toBe('error-file.md'); + expect(result.current.isNewFile).toBe(false); + }); + }); + }); + + describe('workspace changes', () => { + it('reinitializes when workspace changes', async () => { + mockLastOpenedFile.loadLastOpenedFile + .mockResolvedValueOnce('workspace1-file.md') + .mockResolvedValueOnce('workspace2-file.md'); + + const { result, rerender } = renderHook(() => useFileNavigation()); + + // Wait for initial load + await waitFor(() => { + expect(result.current.selectedFile).toBe('workspace1-file.md'); + }); + + expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalledTimes(1); + + // Change workspace + mockWorkspaceData.currentWorkspace = { + id: 2, + name: 'different-workspace', + }; + + rerender(); + + // Should reinitialize with new workspace + await waitFor(() => { + expect(result.current.selectedFile).toBe('workspace2-file.md'); + }); + + expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalledTimes(2); + }); + + it('handles workspace becoming null', async () => { + const { result, rerender } = renderHook(() => useFileNavigation()); + + // Start with workspace + await waitFor(() => { + expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled(); + }); + + // Remove workspace + mockWorkspaceData.currentWorkspace = null; + rerender(); + + // Should still work but with default behavior + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + }); + + it('handles workspace reappearing', async () => { + mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue( + 'restored-file.md' + ); + + const { result, rerender } = renderHook(() => useFileNavigation()); + + // Start with no workspace + mockWorkspaceData.currentWorkspace = null; + rerender(); + + // Add workspace back + mockWorkspaceData.currentWorkspace = { + id: 1, + name: 'restored-workspace', + }; + rerender(); + + // Should reinitialize + await waitFor(() => { + expect(result.current.selectedFile).toBe('restored-file.md'); + }); + + expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled(); + }); + }); + + describe('initialization scenarios', () => { + it('handles loadLastOpenedFile returning empty string', async () => { + mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(''); + + const { result } = renderHook(() => useFileNavigation()); + + await waitFor(() => { + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + }); + }); + + it('handles loadLastOpenedFile errors', async () => { + mockLastOpenedFile.loadLastOpenedFile.mockRejectedValue( + new Error('Load failed') + ); + + const { result } = renderHook(() => useFileNavigation()); + + // Should fallback to default file + await waitFor(() => { + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + }); + }); + + it('handles successful load followed by handleFileSelect', async () => { + mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue( + 'initial-file.md' + ); + + const { result } = renderHook(() => useFileNavigation()); + + // Wait for initial load + await waitFor(() => { + expect(result.current.selectedFile).toBe('initial-file.md'); + expect(result.current.isNewFile).toBe(false); + }); + + // Then select a different file + await act(async () => { + await result.current.handleFileSelect('different-file.md'); + }); + + expect(result.current.selectedFile).toBe('different-file.md'); + expect(result.current.isNewFile).toBe(false); + expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith( + 'different-file.md' + ); + }); + }); + + describe('state consistency', () => { + it('maintains correct isNewFile state for default file', async () => { + const { result } = renderHook(() => useFileNavigation()); + + // Initially should be new file + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + + // Select a real file + await act(async () => { + await result.current.handleFileSelect('real-file.md'); + }); + + // Wait for state to update + await waitFor(() => { + expect(result.current.selectedFile).toBe('real-file.md'); + expect(result.current.isNewFile).toBe(false); + }); + + // Go back to null (should default to default file) + await act(async () => { + await result.current.handleFileSelect(null); + }); + + // Wait for state to update again + await waitFor(() => { + expect(result.current.selectedFile).toBe(DEFAULT_FILE.path); + expect(result.current.isNewFile).toBe(true); + }); + }); + + it('maintains correct isNewFile state for regular files', async () => { + const { result } = renderHook(() => useFileNavigation()); + + const testFiles = ['file1.md', 'file2.md', 'folder/file3.md']; + + for (const file of testFiles) { + await act(async () => { + await result.current.handleFileSelect(file); + }); + + // Wait for each file selection to complete + await waitFor(() => { + expect(result.current.selectedFile).toBe(file); + expect(result.current.isNewFile).toBe(false); + }); + } + }); + }); + + describe('hook interface stability', () => { + it('handleFileSelect function is stable across re-renders', () => { + const { result, rerender } = renderHook(() => useFileNavigation()); + + const initialHandler = result.current.handleFileSelect; + + rerender(); + + expect(result.current.handleFileSelect).toBe(initialHandler); + }); + + it('returns consistent interface', () => { + const { result } = renderHook(() => useFileNavigation()); + + expect(typeof result.current.selectedFile).toBe('string'); + expect(typeof result.current.isNewFile).toBe('boolean'); + expect(typeof result.current.handleFileSelect).toBe('function'); + }); + }); + + describe('integration with useLastOpenedFile', () => { + it('calls loadLastOpenedFile on mount', async () => { + renderHook(() => useFileNavigation()); + + await waitFor(() => { + expect(mockLastOpenedFile.loadLastOpenedFile).toHaveBeenCalled(); + }); + }); + + it('calls saveLastOpenedFile when selecting files', async () => { + const { result } = renderHook(() => useFileNavigation()); + + await act(async () => { + await result.current.handleFileSelect('test-file.md'); + }); + + expect(mockLastOpenedFile.saveLastOpenedFile).toHaveBeenCalledWith( + 'test-file.md' + ); + }); + + it('does not call saveLastOpenedFile for null selections', async () => { + const { result } = renderHook(() => useFileNavigation()); + + await act(async () => { + await result.current.handleFileSelect(null); + }); + + expect(mockLastOpenedFile.saveLastOpenedFile).not.toHaveBeenCalled(); + }); + + it('handles saveLastOpenedFile errors without affecting state', async () => { + mockLastOpenedFile.saveLastOpenedFile.mockRejectedValue( + new Error('Save error') + ); + + const { result } = renderHook(() => useFileNavigation()); + + await act(async () => { + await result.current.handleFileSelect('test-file.md'); + }); + + // State should still be updated despite save error + await waitFor(() => { + expect(result.current.selectedFile).toBe('test-file.md'); + expect(result.current.isNewFile).toBe(false); + }); + }); + }); +}); diff --git a/app/src/hooks/useFileNavigation.ts b/app/src/hooks/useFileNavigation.ts index 3240c19..2218461 100644 --- a/app/src/hooks/useFileNavigation.ts +++ b/app/src/hooks/useFileNavigation.ts @@ -17,35 +17,55 @@ export const useFileNavigation = (): UseFileNavigationResult => { const handleFileSelect = useCallback( async (filePath: string | null): Promise => { - const newPath = filePath || DEFAULT_FILE.path; - setSelectedFile(newPath); - setIsNewFile(!filePath); + // Consider empty string as null + const effectiveFilePath = filePath === '' ? null : filePath; - if (filePath) { - await saveLastOpenedFile(filePath); + if (effectiveFilePath) { + setSelectedFile(effectiveFilePath); + setIsNewFile(false); + + try { + // Try to save the last opened file + await saveLastOpenedFile(effectiveFilePath); + } catch (err) { + // Silently handle the error so state still updates + console.error('Failed to save last opened file:', err); + } + } else if (selectedFile === DEFAULT_FILE.path || filePath === null) { + setSelectedFile(DEFAULT_FILE.path); + setIsNewFile(true); } }, - [saveLastOpenedFile] + [saveLastOpenedFile, selectedFile] ); // Load last opened file when workspace changes useEffect(() => { const initializeFile = async (): Promise => { - setSelectedFile(DEFAULT_FILE.path); - setIsNewFile(true); + try { + setSelectedFile(DEFAULT_FILE.path); + setIsNewFile(true); - const lastFile = await loadLastOpenedFile(); - if (lastFile) { - await handleFileSelect(lastFile); - } else { - await handleFileSelect(null); + const lastFile = await loadLastOpenedFile(); + + if (lastFile) { + setSelectedFile(lastFile); + setIsNewFile(false); + } + } catch (err) { + console.error('Failed to load last opened file:', err); + setSelectedFile(DEFAULT_FILE.path); + setIsNewFile(true); } }; if (currentWorkspace) { void initializeFile(); + } else { + setSelectedFile(DEFAULT_FILE.path); + setIsNewFile(true); } - }, [currentWorkspace, loadLastOpenedFile, handleFileSelect]); + }, [currentWorkspace, loadLastOpenedFile, saveLastOpenedFile]); return { selectedFile, isNewFile, handleFileSelect }; }; diff --git a/app/src/hooks/useFileOperations.test.ts b/app/src/hooks/useFileOperations.test.ts new file mode 100644 index 0000000..20b914d --- /dev/null +++ b/app/src/hooks/useFileOperations.test.ts @@ -0,0 +1,537 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useFileOperations } from './useFileOperations'; +import * as fileApi from '@/api/file'; + +// Mock dependencies +vi.mock('@/api/file'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock the workspace context and git operations +const mockWorkspaceData: { + currentWorkspace: { id: number; name: string } | null; + settings: { + gitAutoCommit: boolean; + gitEnabled: boolean; + gitCommitMsgTemplate: string; + }; +} = { + currentWorkspace: { + id: 1, + name: 'test-workspace', + }, + settings: { + gitAutoCommit: false, + gitEnabled: false, + gitCommitMsgTemplate: '${action} ${filename}', + }, +}; + +const mockGitOperations = { + handleCommitAndPush: vi.fn(), +}; + +vi.mock('../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => mockWorkspaceData, +})); + +vi.mock('./useGitOperations', () => ({ + useGitOperations: () => mockGitOperations, +})); + +// Import notifications for assertions +import { notifications } from '@mantine/notifications'; + +describe('useFileOperations', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset workspace data to defaults + mockWorkspaceData.currentWorkspace = { + id: 1, + name: 'test-workspace', + }; + mockWorkspaceData.settings = { + gitAutoCommit: false, + gitEnabled: false, + gitCommitMsgTemplate: '${action} ${filename}', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handleSave', () => { + it('saves file successfully and shows success notification', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + + const { result } = renderHook(() => useFileOperations()); + + let saveResult: boolean | undefined; + await act(async () => { + saveResult = await result.current.handleSave( + 'test.md', + '# Test Content' + ); + }); + + expect(saveResult).toBe(true); + expect(mockSaveFile).toHaveBeenCalledWith( + 'test-workspace', + 'test.md', + '# Test Content' + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'File saved successfully', + color: 'green', + }); + }); + + it('handles save errors and shows error notification', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockSaveFile.mockRejectedValue(new Error('Save failed')); + + const { result } = renderHook(() => useFileOperations()); + + let saveResult: boolean | undefined; + await act(async () => { + saveResult = await result.current.handleSave( + 'test.md', + '# Test Content' + ); + }); + + expect(saveResult).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error saving file:', + expect.any(Error) + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to save file', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('returns false when no workspace is available', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useFileOperations()); + + let saveResult: boolean | undefined; + await act(async () => { + saveResult = await result.current.handleSave( + 'test.md', + '# Test Content' + ); + }); + + expect(saveResult).toBe(false); + expect(fileApi.saveFile).not.toHaveBeenCalled(); + }); + + it('triggers auto-commit when enabled', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + + // Enable auto-commit + mockWorkspaceData.settings.gitAutoCommit = true; + mockWorkspaceData.settings.gitEnabled = true; + + const { result } = renderHook(() => useFileOperations()); + + await act(async () => { + await result.current.handleSave('test.md', '# Test Content'); + }); + + expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith( + 'Update test.md' + ); + }); + + it('uses custom commit message template', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'docs/readme.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + + // Enable auto-commit with custom template + mockWorkspaceData.settings.gitAutoCommit = true; + mockWorkspaceData.settings.gitEnabled = true; + mockWorkspaceData.settings.gitCommitMsgTemplate = + 'Modified ${filename} - ${action}'; + + const { result } = renderHook(() => useFileOperations()); + + await act(async () => { + await result.current.handleSave('docs/readme.md', '# Documentation'); + }); + + expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith( + 'Modified docs/readme.md - update' + ); + }); + }); + + describe('handleDelete', () => { + it('deletes file successfully and shows success notification', async () => { + const mockDeleteFile = vi.mocked(fileApi.deleteFile); + mockDeleteFile.mockResolvedValue(undefined); + + const { result } = renderHook(() => useFileOperations()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.handleDelete('test.md'); + }); + + expect(deleteResult).toBe(true); + expect(mockDeleteFile).toHaveBeenCalledWith('test-workspace', 'test.md'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'File deleted successfully', + color: 'green', + }); + }); + + it('handles delete errors and shows error notification', async () => { + const mockDeleteFile = vi.mocked(fileApi.deleteFile); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockDeleteFile.mockRejectedValue(new Error('Delete failed')); + + const { result } = renderHook(() => useFileOperations()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.handleDelete('test.md'); + }); + + expect(deleteResult).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error deleting file:', + expect.any(Error) + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to delete file', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('returns false when no workspace is available', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useFileOperations()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.handleDelete('test.md'); + }); + + expect(deleteResult).toBe(false); + expect(fileApi.deleteFile).not.toHaveBeenCalled(); + }); + + it('triggers auto-commit when enabled', async () => { + const mockDeleteFile = vi.mocked(fileApi.deleteFile); + mockDeleteFile.mockResolvedValue(undefined); + + // Enable auto-commit + mockWorkspaceData.settings.gitAutoCommit = true; + mockWorkspaceData.settings.gitEnabled = true; + + const { result } = renderHook(() => useFileOperations()); + + await act(async () => { + await result.current.handleDelete('old-file.md'); + }); + + expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith( + 'Delete old-file.md' + ); + }); + }); + + describe('handleCreate', () => { + it('creates file successfully with default content', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'new.md', + size: 0, + updatedAt: '2024-01-01T00:00:00Z', + }); + + const { result } = renderHook(() => useFileOperations()); + + let createResult: boolean | undefined; + await act(async () => { + createResult = await result.current.handleCreate('new.md'); + }); + + expect(createResult).toBe(true); + expect(mockSaveFile).toHaveBeenCalledWith('test-workspace', 'new.md', ''); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'File created successfully', + color: 'green', + }); + }); + + it('creates file with custom initial content', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'template.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + + const { result } = renderHook(() => useFileOperations()); + + let createResult: boolean | undefined; + await act(async () => { + createResult = await result.current.handleCreate( + 'template.md', + '# Template\n\nContent here' + ); + }); + + expect(createResult).toBe(true); + expect(mockSaveFile).toHaveBeenCalledWith( + 'test-workspace', + 'template.md', + '# Template\n\nContent here' + ); + }); + + it('handles create errors and shows error notification', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockSaveFile.mockRejectedValue(new Error('Create failed')); + + const { result } = renderHook(() => useFileOperations()); + + let createResult: boolean | undefined; + await act(async () => { + createResult = await result.current.handleCreate('new.md'); + }); + + expect(createResult).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Error creating new file:', + expect.any(Error) + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to create new file', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('returns false when no workspace is available', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useFileOperations()); + + let createResult: boolean | undefined; + await act(async () => { + createResult = await result.current.handleCreate('new.md'); + }); + + expect(createResult).toBe(false); + expect(fileApi.saveFile).not.toHaveBeenCalled(); + }); + + it('triggers auto-commit when enabled', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'new-file.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + + // Enable auto-commit + mockWorkspaceData.settings.gitAutoCommit = true; + mockWorkspaceData.settings.gitEnabled = true; + + const { result } = renderHook(() => useFileOperations()); + + await act(async () => { + await result.current.handleCreate('new-file.md', 'Initial content'); + }); + + expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith( + 'Create new-file.md' + ); + }); + }); + + describe('auto-commit behavior', () => { + it('does not auto-commit when git is disabled', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + + // Enable auto-commit but disable git + mockWorkspaceData.settings.gitAutoCommit = true; + mockWorkspaceData.settings.gitEnabled = false; + + const { result } = renderHook(() => useFileOperations()); + + await act(async () => { + await result.current.handleSave('test.md', 'content'); + }); + + expect(mockGitOperations.handleCommitAndPush).not.toHaveBeenCalled(); + }); + + it('does not auto-commit when auto-commit is disabled', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + + // Enable git but disable auto-commit + mockWorkspaceData.settings.gitAutoCommit = false; + mockWorkspaceData.settings.gitEnabled = true; + + const { result } = renderHook(() => useFileOperations()); + + await act(async () => { + await result.current.handleSave('test.md', 'content'); + }); + + expect(mockGitOperations.handleCommitAndPush).not.toHaveBeenCalled(); + }); + + it('capitalizes commit messages correctly', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + mockSaveFile.mockResolvedValue({ + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + + // Enable auto-commit with lowercase template + mockWorkspaceData.settings.gitAutoCommit = true; + mockWorkspaceData.settings.gitEnabled = true; + mockWorkspaceData.settings.gitCommitMsgTemplate = 'updated ${filename}'; + + const { result } = renderHook(() => useFileOperations()); + + await act(async () => { + await result.current.handleSave('test.md', 'content'); + }); + + expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith( + 'Updated test.md' + ); + }); + + it('handles different file actions correctly', async () => { + const mockSaveFile = vi.mocked(fileApi.saveFile); + const mockDeleteFile = vi.mocked(fileApi.deleteFile); + + mockSaveFile.mockResolvedValue({ + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T00:00:00Z', + }); + mockDeleteFile.mockResolvedValue(undefined); + + // Enable auto-commit + mockWorkspaceData.settings.gitAutoCommit = true; + mockWorkspaceData.settings.gitEnabled = true; + mockWorkspaceData.settings.gitCommitMsgTemplate = + '${action}: ${filename}'; + + const { result } = renderHook(() => useFileOperations()); + + // Test create action + await act(async () => { + await result.current.handleCreate('new.md'); + }); + expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith( + 'Create: new.md' + ); + + // Test update action + await act(async () => { + await result.current.handleSave('existing.md', 'content'); + }); + expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith( + 'Update: existing.md' + ); + + // Test delete action + await act(async () => { + await result.current.handleDelete('old.md'); + }); + expect(mockGitOperations.handleCommitAndPush).toHaveBeenCalledWith( + 'Delete: old.md' + ); + }); + }); + + describe('hook interface', () => { + it('returns correct function interface', () => { + const { result } = renderHook(() => useFileOperations()); + + expect(typeof result.current.handleSave).toBe('function'); + expect(typeof result.current.handleDelete).toBe('function'); + expect(typeof result.current.handleCreate).toBe('function'); + }); + + it('functions are stable across re-renders', () => { + const { result, rerender } = renderHook(() => useFileOperations()); + + const initialHandlers = { + handleSave: result.current.handleSave, + handleDelete: result.current.handleDelete, + handleCreate: result.current.handleCreate, + }; + + rerender(); + + expect(result.current.handleSave).toBe(initialHandlers.handleSave); + expect(result.current.handleDelete).toBe(initialHandlers.handleDelete); + expect(result.current.handleCreate).toBe(initialHandlers.handleCreate); + }); + }); +}); diff --git a/app/src/hooks/useGitOperations.test.ts b/app/src/hooks/useGitOperations.test.ts new file mode 100644 index 0000000..1046fc7 --- /dev/null +++ b/app/src/hooks/useGitOperations.test.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useGitOperations } from './useGitOperations'; +import * as gitApi from '@/api/git'; + +// Mock dependencies +vi.mock('@/api/git'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock the workspace context +const mockWorkspaceData: { + currentWorkspace: { id: number; name: string } | null; + settings: { gitEnabled: boolean }; +} = { + currentWorkspace: { + id: 1, + name: 'test-workspace', + }, + settings: { + gitEnabled: true, + }, +}; + +vi.mock('../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => mockWorkspaceData, +})); + +// Import notifications for assertions +import { notifications } from '@mantine/notifications'; + +describe('useGitOperations', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset workspace data to defaults + mockWorkspaceData.currentWorkspace = { + id: 1, + name: 'test-workspace', + }; + mockWorkspaceData.settings = { + gitEnabled: true, + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('handlePull', () => { + it('pulls changes successfully and shows success notification', async () => { + const mockPullChanges = vi.mocked(gitApi.pullChanges); + mockPullChanges.mockResolvedValue('Successfully pulled latest changes'); + + const { result } = renderHook(() => useGitOperations()); + + let pullResult: boolean | undefined; + await act(async () => { + pullResult = await result.current.handlePull(); + }); + + expect(pullResult).toBe(true); + expect(mockPullChanges).toHaveBeenCalledWith('test-workspace'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Successfully pulled latest changes', + color: 'green', + }); + }); + + it('handles pull errors and shows error notification', async () => { + const mockPullChanges = vi.mocked(gitApi.pullChanges); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockPullChanges.mockRejectedValue(new Error('Pull failed')); + + const { result } = renderHook(() => useGitOperations()); + + let pullResult: boolean | undefined; + await act(async () => { + pullResult = await result.current.handlePull(); + }); + + expect(pullResult).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to pull latest changes:', + expect.any(Error) + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to pull latest changes', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('returns false when no workspace is available', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useGitOperations()); + + let pullResult: boolean | undefined; + await act(async () => { + pullResult = await result.current.handlePull(); + }); + + expect(pullResult).toBe(false); + expect(gitApi.pullChanges).not.toHaveBeenCalled(); + }); + + it('returns false when git is disabled', async () => { + mockWorkspaceData.settings.gitEnabled = false; + + const { result } = renderHook(() => useGitOperations()); + + let pullResult: boolean | undefined; + await act(async () => { + pullResult = await result.current.handlePull(); + }); + + expect(pullResult).toBe(false); + expect(gitApi.pullChanges).not.toHaveBeenCalled(); + }); + + it('handles pull with different response messages', async () => { + const mockPullChanges = vi.mocked(gitApi.pullChanges); + mockPullChanges.mockResolvedValue('Already up to date'); + + const { result } = renderHook(() => useGitOperations()); + + await act(async () => { + await result.current.handlePull(); + }); + + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Already up to date', + color: 'green', + }); + }); + }); + + describe('handleCommitAndPush', () => { + it('commits and pushes successfully with commit hash', async () => { + const mockCommitAndPush = vi.mocked(gitApi.commitAndPush); + mockCommitAndPush.mockResolvedValue('abc123def456'); + + const { result } = renderHook(() => useGitOperations()); + + await act(async () => { + await result.current.handleCommitAndPush('Add new feature'); + }); + + expect(mockCommitAndPush).toHaveBeenCalledWith( + 'test-workspace', + 'Add new feature' + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Successfully committed and pushed changes abc123def456', + color: 'green', + }); + }); + + it('handles commit errors and shows error notification', async () => { + const mockCommitAndPush = vi.mocked(gitApi.commitAndPush); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockCommitAndPush.mockRejectedValue(new Error('Commit failed')); + + const { result } = renderHook(() => useGitOperations()); + + await act(async () => { + await result.current.handleCommitAndPush('Failed commit'); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to commit and push changes:', + expect.any(Error) + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to commit and push changes', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('does nothing when no workspace is available', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useGitOperations()); + + await act(async () => { + await result.current.handleCommitAndPush('Test commit'); + }); + + expect(gitApi.commitAndPush).not.toHaveBeenCalled(); + expect(notifications.show).not.toHaveBeenCalled(); + }); + + it('does nothing when git is disabled', async () => { + mockWorkspaceData.settings.gitEnabled = false; + + const { result } = renderHook(() => useGitOperations()); + + await act(async () => { + await result.current.handleCommitAndPush('Test commit'); + }); + + expect(gitApi.commitAndPush).not.toHaveBeenCalled(); + expect(notifications.show).not.toHaveBeenCalled(); + }); + + it('handles empty commit messages', async () => { + const mockCommitAndPush = vi.mocked(gitApi.commitAndPush); + mockCommitAndPush.mockResolvedValue('xyz789abc123'); + + const { result } = renderHook(() => useGitOperations()); + + await act(async () => { + await result.current.handleCommitAndPush(''); + }); + + expect(mockCommitAndPush).toHaveBeenCalledWith('test-workspace', ''); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Successfully committed and pushed changes xyz789abc123', + color: 'green', + }); + }); + + it('handles long commit messages', async () => { + const mockCommitAndPush = vi.mocked(gitApi.commitAndPush); + mockCommitAndPush.mockResolvedValue('longcommithash123456789'); + + const longMessage = + 'This is a very long commit message that describes in detail all the changes that were made to the codebase including bug fixes, new features, and documentation updates'; + + const { result } = renderHook(() => useGitOperations()); + + await act(async () => { + await result.current.handleCommitAndPush(longMessage); + }); + + expect(mockCommitAndPush).toHaveBeenCalledWith( + 'test-workspace', + longMessage + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: + 'Successfully committed and pushed changes longcommithash123456789', + color: 'green', + }); + }); + + it('handles commit with special characters in message', async () => { + const mockCommitAndPush = vi.mocked(gitApi.commitAndPush); + mockCommitAndPush.mockResolvedValue('special123hash'); + + const specialMessage = + 'Fix: update file with special chars àáâãäå & symbols!@#$%'; + + const { result } = renderHook(() => useGitOperations()); + + await act(async () => { + await result.current.handleCommitAndPush(specialMessage); + }); + + expect(mockCommitAndPush).toHaveBeenCalledWith( + 'test-workspace', + specialMessage + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Successfully committed and pushed changes special123hash', + color: 'green', + }); + }); + }); + + describe('workspace and settings dependencies', () => { + it('handles workspace changes correctly', async () => { + const mockPullChanges = vi.mocked(gitApi.pullChanges); + mockPullChanges.mockResolvedValue('Success'); + + const { result, rerender } = renderHook(() => useGitOperations()); + + // Test with initial workspace + await act(async () => { + await result.current.handlePull(); + }); + + expect(mockPullChanges).toHaveBeenCalledWith('test-workspace'); + + // Change workspace + mockWorkspaceData.currentWorkspace = { + id: 2, + name: 'different-workspace', + }; + + rerender(); + + await act(async () => { + await result.current.handlePull(); + }); + + expect(mockPullChanges).toHaveBeenCalledWith('different-workspace'); + }); + + it('handles git settings changes correctly', async () => { + const { result, rerender } = renderHook(() => useGitOperations()); + + // Initially git is enabled + expect(mockWorkspaceData.settings.gitEnabled).toBe(true); + + // Disable git + mockWorkspaceData.settings.gitEnabled = false; + rerender(); + + let pullResult: boolean | undefined; + await act(async () => { + pullResult = await result.current.handlePull(); + }); + + expect(pullResult).toBe(false); + expect(gitApi.pullChanges).not.toHaveBeenCalled(); + }); + }); + + describe('hook interface', () => { + it('returns correct function interface', () => { + const { result } = renderHook(() => useGitOperations()); + + expect(typeof result.current.handlePull).toBe('function'); + expect(typeof result.current.handleCommitAndPush).toBe('function'); + }); + + it('functions are stable across re-renders', () => { + const { result, rerender } = renderHook(() => useGitOperations()); + + const initialHandlers = { + handlePull: result.current.handlePull, + handleCommitAndPush: result.current.handleCommitAndPush, + }; + + rerender(); + + expect(result.current.handlePull).toBe(initialHandlers.handlePull); + expect(result.current.handleCommitAndPush).toBe( + initialHandlers.handleCommitAndPush + ); + }); + }); + + describe('edge cases', () => { + it('handles null workspace gracefully', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useGitOperations()); + + let pullResult: boolean | undefined; + await act(async () => { + pullResult = await result.current.handlePull(); + }); + + expect(pullResult).toBe(false); + expect(gitApi.pullChanges).not.toHaveBeenCalled(); + }); + + it('handles undefined workspace name gracefully', async () => { + mockWorkspaceData.currentWorkspace = { + id: 1, + name: undefined!, + }; + + const { result } = renderHook(() => useGitOperations()); + + let pullResult: boolean | undefined; + await act(async () => { + pullResult = await result.current.handlePull(); + }); + + expect(pullResult).toBe(false); + expect(gitApi.pullChanges).not.toHaveBeenCalled(); + }); + + it('handles missing settings gracefully', async () => { + mockWorkspaceData.settings = { + gitEnabled: undefined!, + }; + + const { result } = renderHook(() => useGitOperations()); + + let pullResult: boolean | undefined; + await act(async () => { + pullResult = await result.current.handlePull(); + }); + + expect(pullResult).toBe(false); + expect(gitApi.pullChanges).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/hooks/useGitOperations.ts b/app/src/hooks/useGitOperations.ts index 601dff1..0745606 100644 --- a/app/src/hooks/useGitOperations.ts +++ b/app/src/hooks/useGitOperations.ts @@ -13,13 +13,14 @@ export const useGitOperations = (): UseGitOperationsResult => { const { currentWorkspace, settings } = useWorkspaceData(); const handlePull = useCallback(async (): Promise => { - if (!currentWorkspace || !settings.gitEnabled) return false; + if (!currentWorkspace || !settings.gitEnabled || !currentWorkspace.name) + return false; try { - await pullChanges(currentWorkspace.name); + const message = await pullChanges(currentWorkspace.name); notifications.show({ title: 'Success', - message: 'Successfully pulled latest changes', + message: message || 'Successfully pulled latest changes', color: 'green', }); return true; @@ -37,11 +38,12 @@ export const useGitOperations = (): UseGitOperationsResult => { const handleCommitAndPush = useCallback( async (message: string): Promise => { if (!currentWorkspace || !settings.gitEnabled) return; - const commitHash: CommitHash = await commitAndPush( - currentWorkspace.name, - message - ); + try { + const commitHash: CommitHash = await commitAndPush( + currentWorkspace.name, + message + ); notifications.show({ title: 'Success', message: 'Successfully committed and pushed changes ' + commitHash, diff --git a/app/src/hooks/useLastOpenedFile.test.ts b/app/src/hooks/useLastOpenedFile.test.ts new file mode 100644 index 0000000..0d288de --- /dev/null +++ b/app/src/hooks/useLastOpenedFile.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useLastOpenedFile } from './useLastOpenedFile'; +import * as fileApi from '@/api/file'; + +// Mock dependencies +vi.mock('@/api/file'); + +// Mock the workspace context +const mockWorkspaceData: { + currentWorkspace: { id: number; name: string } | null; +} = { + currentWorkspace: { + id: 1, + name: 'test-workspace', + }, +}; + +vi.mock('../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => mockWorkspaceData, +})); + +describe('useLastOpenedFile', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset workspace data to defaults + mockWorkspaceData.currentWorkspace = { + id: 1, + name: 'test-workspace', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('loadLastOpenedFile', () => { + it('loads last opened file successfully', async () => { + const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile); + mockGetLastOpenedFile.mockResolvedValue('documents/readme.md'); + + const { result } = renderHook(() => useLastOpenedFile()); + + let lastFile: string | null = ''; + await act(async () => { + lastFile = await result.current.loadLastOpenedFile(); + }); + + expect(lastFile).toBe('documents/readme.md'); + expect(mockGetLastOpenedFile).toHaveBeenCalledWith('test-workspace'); + }); + + it('returns null for empty response', async () => { + const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile); + mockGetLastOpenedFile.mockResolvedValue(''); + + const { result } = renderHook(() => useLastOpenedFile()); + + let lastFile: string | null = ''; + await act(async () => { + lastFile = await result.current.loadLastOpenedFile(); + }); + + expect(lastFile).toBeNull(); + expect(mockGetLastOpenedFile).toHaveBeenCalledWith('test-workspace'); + }); + + it('handles API errors gracefully', async () => { + const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockGetLastOpenedFile.mockRejectedValue(new Error('API Error')); + + const { result } = renderHook(() => useLastOpenedFile()); + + let lastFile: string | null = ''; + await act(async () => { + lastFile = await result.current.loadLastOpenedFile(); + }); + + expect(lastFile).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to load last opened file:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + + it('returns null when no workspace is available', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useLastOpenedFile()); + + let lastFile: string | null = ''; + await act(async () => { + lastFile = await result.current.loadLastOpenedFile(); + }); + + expect(lastFile).toBeNull(); + expect(fileApi.getLastOpenedFile).not.toHaveBeenCalled(); + }); + + it('handles different file path formats', async () => { + const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile); + + // Test various file path formats + const testCases = [ + 'simple.md', + 'folder/file.md', + 'deep/nested/path/document.md', + 'file with spaces.md', + 'special-chars_123.md', + ]; + + const { result } = renderHook(() => useLastOpenedFile()); + + for (const testPath of testCases) { + mockGetLastOpenedFile.mockResolvedValueOnce(testPath); + + let lastFile: string | null = ''; + await act(async () => { + lastFile = await result.current.loadLastOpenedFile(); + }); + + expect(lastFile).toBe(testPath); + } + + expect(mockGetLastOpenedFile).toHaveBeenCalledTimes(testCases.length); + }); + }); + + describe('saveLastOpenedFile', () => { + it('saves last opened file successfully', async () => { + const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile); + mockUpdateLastOpenedFile.mockResolvedValue(undefined); + + const { result } = renderHook(() => useLastOpenedFile()); + + await act(async () => { + await result.current.saveLastOpenedFile('notes/todo.md'); + }); + + expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith( + 'test-workspace', + 'notes/todo.md' + ); + }); + + it('handles API errors gracefully', async () => { + const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockUpdateLastOpenedFile.mockRejectedValue(new Error('Save Error')); + + const { result } = renderHook(() => useLastOpenedFile()); + + await act(async () => { + await result.current.saveLastOpenedFile('error.md'); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to save last opened file:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + + it('does nothing when no workspace is available', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useLastOpenedFile()); + + await act(async () => { + await result.current.saveLastOpenedFile('test.md'); + }); + + expect(fileApi.updateLastOpenedFile).not.toHaveBeenCalled(); + }); + + it('does nothing when file path is empty', async () => { + const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile); + mockUpdateLastOpenedFile.mockResolvedValue(undefined); + + const { result } = renderHook(() => useLastOpenedFile()); + + await act(async () => { + await result.current.saveLastOpenedFile(''); + }); + + expect(mockUpdateLastOpenedFile).not.toHaveBeenCalledWith( + 'test-workspace', + '' + ); + }); + + it('handles different file path formats', async () => { + const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile); + mockUpdateLastOpenedFile.mockResolvedValue(undefined); + + const testCases = [ + 'simple.md', + 'folder/file.md', + 'deep/nested/path/document.md', + 'file with spaces.md', + 'special-chars_123.md', + 'unicode-文件.md', + ]; + + const { result } = renderHook(() => useLastOpenedFile()); + + for (const testPath of testCases) { + await act(async () => { + await result.current.saveLastOpenedFile(testPath); + }); + + expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith( + 'test-workspace', + testPath + ); + } + + expect(mockUpdateLastOpenedFile).toHaveBeenCalledTimes(testCases.length); + }); + }); + + describe('workspace dependency', () => { + it('handles workspace changes correctly', async () => { + const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile); + const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile); + + mockGetLastOpenedFile.mockResolvedValue('file.md'); + mockUpdateLastOpenedFile.mockResolvedValue(undefined); + + const { result, rerender } = renderHook(() => useLastOpenedFile()); + + // Test with initial workspace + await act(async () => { + await result.current.loadLastOpenedFile(); + await result.current.saveLastOpenedFile('test.md'); + }); + + expect(mockGetLastOpenedFile).toHaveBeenCalledWith('test-workspace'); + expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith( + 'test-workspace', + 'test.md' + ); + + // Change workspace + mockWorkspaceData.currentWorkspace = { + id: 2, + name: 'different-workspace', + }; + + rerender(); + + await act(async () => { + await result.current.loadLastOpenedFile(); + await result.current.saveLastOpenedFile('other.md'); + }); + + expect(mockGetLastOpenedFile).toHaveBeenCalledWith('different-workspace'); + expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith( + 'different-workspace', + 'other.md' + ); + }); + + it('handles workspace becoming null', async () => { + const { result, rerender } = renderHook(() => useLastOpenedFile()); + + // Start with workspace + expect(mockWorkspaceData.currentWorkspace).not.toBeNull(); + + // Remove workspace + mockWorkspaceData.currentWorkspace = null; + rerender(); + + let lastFile: string | null = ''; + await act(async () => { + lastFile = await result.current.loadLastOpenedFile(); + await result.current.saveLastOpenedFile('test.md'); + }); + + expect(lastFile).toBeNull(); + expect(fileApi.getLastOpenedFile).not.toHaveBeenCalled(); + expect(fileApi.updateLastOpenedFile).not.toHaveBeenCalled(); + }); + }); + + describe('hook interface', () => { + it('returns correct function interface', () => { + const { result } = renderHook(() => useLastOpenedFile()); + + expect(typeof result.current.loadLastOpenedFile).toBe('function'); + expect(typeof result.current.saveLastOpenedFile).toBe('function'); + }); + + it('functions are stable across re-renders', () => { + const { result, rerender } = renderHook(() => useLastOpenedFile()); + + const initialHandlers = { + loadLastOpenedFile: result.current.loadLastOpenedFile, + saveLastOpenedFile: result.current.saveLastOpenedFile, + }; + + rerender(); + + expect(result.current.loadLastOpenedFile).toBe( + initialHandlers.loadLastOpenedFile + ); + expect(result.current.saveLastOpenedFile).toBe( + initialHandlers.saveLastOpenedFile + ); + }); + }); + + describe('integration scenarios', () => { + it('handles load after save', async () => { + const mockGetLastOpenedFile = vi.mocked(fileApi.getLastOpenedFile); + const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile); + + mockUpdateLastOpenedFile.mockResolvedValue(undefined); + mockGetLastOpenedFile.mockResolvedValue('saved-file.md'); + + const { result } = renderHook(() => useLastOpenedFile()); + + // Save a file + await act(async () => { + await result.current.saveLastOpenedFile('saved-file.md'); + }); + + // Load the last opened file + let lastFile: string | null = ''; + await act(async () => { + lastFile = await result.current.loadLastOpenedFile(); + }); + + expect(lastFile).toBe('saved-file.md'); + expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith( + 'test-workspace', + 'saved-file.md' + ); + expect(mockGetLastOpenedFile).toHaveBeenCalledWith('test-workspace'); + }); + + it('handles multiple rapid saves', async () => { + const mockUpdateLastOpenedFile = vi.mocked(fileApi.updateLastOpenedFile); + mockUpdateLastOpenedFile.mockResolvedValue(undefined); + + const { result } = renderHook(() => useLastOpenedFile()); + + const filePaths = ['file1.md', 'file2.md', 'file3.md']; + + // Rapidly save multiple files + await act(async () => { + await Promise.all( + filePaths.map((path) => result.current.saveLastOpenedFile(path)) + ); + }); + + expect(mockUpdateLastOpenedFile).toHaveBeenCalledTimes(3); + filePaths.forEach((path) => { + expect(mockUpdateLastOpenedFile).toHaveBeenCalledWith( + 'test-workspace', + path + ); + }); + }); + }); +}); diff --git a/app/src/hooks/useLastOpenedFile.ts b/app/src/hooks/useLastOpenedFile.ts index 5c0ceeb..2b2469c 100644 --- a/app/src/hooks/useLastOpenedFile.ts +++ b/app/src/hooks/useLastOpenedFile.ts @@ -24,7 +24,7 @@ export const useLastOpenedFile = (): UseLastOpenedFileResult => { const saveLastOpenedFile = useCallback( async (filePath: string): Promise => { - if (!currentWorkspace) return; + if (!currentWorkspace || !filePath.trim()) return; try { await updateLastOpenedFile(currentWorkspace.name, filePath); diff --git a/app/src/hooks/useProfileSettings.test.ts b/app/src/hooks/useProfileSettings.test.ts new file mode 100644 index 0000000..449d25b --- /dev/null +++ b/app/src/hooks/useProfileSettings.test.ts @@ -0,0 +1,500 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useProfileSettings } from './useProfileSettings'; +import * as userApi from '@/api/user'; +import type { UpdateProfileRequest } from '@/types/api'; +import { UserRole, type User } from '@/types/models'; + +// Mock dependencies +vi.mock('@/api/user'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Import notifications for assertions +import { notifications } from '@mantine/notifications'; + +// 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, +}; + +describe('useProfileSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('returns correct initial loading state', () => { + const { result } = renderHook(() => useProfileSettings()); + + expect(result.current.loading).toBe(false); + expect(typeof result.current.updateProfile).toBe('function'); + expect(typeof result.current.deleteAccount).toBe('function'); + }); + }); + + describe('updateProfile', () => { + it('updates profile successfully', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + const updatedUser: User = { + ...mockUser, + displayName: 'Updated Name', + }; + mockUpdateProfile.mockResolvedValue(updatedUser); + + const { result } = renderHook(() => useProfileSettings()); + + const updateRequest: UpdateProfileRequest = { + displayName: 'Updated Name', + }; + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile(updateRequest); + }); + + expect(returnedUser).toEqual(updatedUser); + expect(mockUpdateProfile).toHaveBeenCalledWith(updateRequest); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Profile updated successfully', + color: 'green', + }); + expect(result.current.loading).toBe(false); + }); + + it('updates email successfully', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + const updatedUser: User = { + ...mockUser, + email: 'newemail@example.com', + }; + mockUpdateProfile.mockResolvedValue(updatedUser); + + const { result } = renderHook(() => useProfileSettings()); + + const updateRequest: UpdateProfileRequest = { + email: 'newemail@example.com', + currentPassword: 'current123', + }; + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile(updateRequest); + }); + + expect(returnedUser).toEqual(updatedUser); + expect(mockUpdateProfile).toHaveBeenCalledWith(updateRequest); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Profile updated successfully', + color: 'green', + }); + }); + + it('updates password successfully', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + mockUpdateProfile.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useProfileSettings()); + + const updateRequest: UpdateProfileRequest = { + currentPassword: 'oldpass123', + newPassword: 'newpass456', + }; + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile(updateRequest); + }); + + expect(returnedUser).toEqual(mockUser); + expect(mockUpdateProfile).toHaveBeenCalledWith(updateRequest); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Profile updated successfully', + color: 'green', + }); + }); + + it('updates multiple fields successfully', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + const updatedUser: User = { + ...mockUser, + displayName: 'New Display Name', + email: 'updated@example.com', + }; + mockUpdateProfile.mockResolvedValue(updatedUser); + + const { result } = renderHook(() => useProfileSettings()); + + const updateRequest: UpdateProfileRequest = { + displayName: 'New Display Name', + email: 'updated@example.com', + currentPassword: 'current123', + }; + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile(updateRequest); + }); + + expect(returnedUser).toEqual(updatedUser); + expect(mockUpdateProfile).toHaveBeenCalledWith(updateRequest); + }); + + it('shows loading state during update', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + let resolveUpdate: (value: User) => void; + const updatePromise = new Promise((resolve) => { + resolveUpdate = resolve; + }); + mockUpdateProfile.mockReturnValue(updatePromise); + + const { result } = renderHook(() => useProfileSettings()); + + // Start update + act(() => { + void result.current.updateProfile({ displayName: 'Test' }); + }); + + // Should be loading + expect(result.current.loading).toBe(true); + + // Resolve the promise + await act(async () => { + if (resolveUpdate) resolveUpdate(mockUser); + await updatePromise; + }); + + // Should no longer be loading + expect(result.current.loading).toBe(false); + }); + + it('handles password errors specifically', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + mockUpdateProfile.mockRejectedValue( + new Error('Current password is incorrect') + ); + + const { result } = renderHook(() => useProfileSettings()); + + const updateRequest: UpdateProfileRequest = { + currentPassword: 'wrongpass', + newPassword: 'newpass123', + }; + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile(updateRequest); + }); + + expect(returnedUser).toBeNull(); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Current password is incorrect', + color: 'red', + }); + expect(result.current.loading).toBe(false); + }); + + it('handles email errors specifically', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + mockUpdateProfile.mockRejectedValue(new Error('email already exists')); + + const { result } = renderHook(() => useProfileSettings()); + + const updateRequest: UpdateProfileRequest = { + email: 'existing@example.com', + }; + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile(updateRequest); + }); + + expect(returnedUser).toBeNull(); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Email is already in use', + color: 'red', + }); + }); + + it('handles generic update errors', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + mockUpdateProfile.mockRejectedValue(new Error('Server error')); + + const { result } = renderHook(() => useProfileSettings()); + + const updateRequest: UpdateProfileRequest = { + displayName: 'Test Name', + }; + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile(updateRequest); + }); + + expect(returnedUser).toBeNull(); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to update profile', + color: 'red', + }); + }); + + it('handles non-Error rejection', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + mockUpdateProfile.mockRejectedValue('String error'); + + const { result } = renderHook(() => useProfileSettings()); + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile({ + displayName: 'Test', + }); + }); + + expect(returnedUser).toBeNull(); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to update profile', + color: 'red', + }); + }); + }); + + describe('deleteAccount', () => { + it('deletes account successfully', async () => { + const mockDeleteUser = vi.mocked(userApi.deleteUser); + mockDeleteUser.mockResolvedValue(undefined); + + const { result } = renderHook(() => useProfileSettings()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.deleteAccount('password123'); + }); + + expect(deleteResult).toBe(true); + expect(mockDeleteUser).toHaveBeenCalledWith('password123'); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Account deleted successfully', + color: 'green', + }); + expect(result.current.loading).toBe(false); + }); + + it('shows loading state during deletion', async () => { + const mockDeleteUser = vi.mocked(userApi.deleteUser); + let resolveDelete: () => void; + const deletePromise = new Promise((resolve) => { + resolveDelete = resolve; + }); + mockDeleteUser.mockReturnValue(deletePromise); + + const { result } = renderHook(() => useProfileSettings()); + + // Start deletion + act(() => { + void result.current.deleteAccount('password123'); + }); + + // Should be loading + expect(result.current.loading).toBe(true); + + // Resolve the promise + await act(async () => { + if (resolveDelete) resolveDelete(); + await deletePromise; + }); + + // Should no longer be loading + expect(result.current.loading).toBe(false); + }); + + it('handles delete errors with error message', async () => { + const mockDeleteUser = vi.mocked(userApi.deleteUser); + mockDeleteUser.mockRejectedValue(new Error('Invalid password')); + + const { result } = renderHook(() => useProfileSettings()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.deleteAccount('wrongpass'); + }); + + expect(deleteResult).toBe(false); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Invalid password', + color: 'red', + }); + expect(result.current.loading).toBe(false); + }); + + it('handles generic delete errors', async () => { + const mockDeleteUser = vi.mocked(userApi.deleteUser); + mockDeleteUser.mockRejectedValue(new Error('Server error')); + + const { result } = renderHook(() => useProfileSettings()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.deleteAccount('password123'); + }); + + expect(deleteResult).toBe(false); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Server error', + color: 'red', + }); + }); + + it('handles non-Error rejection in delete', async () => { + const mockDeleteUser = vi.mocked(userApi.deleteUser); + mockDeleteUser.mockRejectedValue('String error'); + + const { result } = renderHook(() => useProfileSettings()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.deleteAccount('password123'); + }); + + expect(deleteResult).toBe(false); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to delete account', + color: 'red', + }); + }); + + it('handles empty password', async () => { + const mockDeleteUser = vi.mocked(userApi.deleteUser); + mockDeleteUser.mockResolvedValue(undefined); + + const { result } = renderHook(() => useProfileSettings()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.deleteAccount(''); + }); + + expect(deleteResult).toBe(true); + expect(mockDeleteUser).toHaveBeenCalledWith(''); + }); + }); + + describe('concurrent operations', () => { + it('handles concurrent profile updates', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + mockUpdateProfile + .mockResolvedValueOnce({ ...mockUser, displayName: 'Name 1' }) + .mockResolvedValueOnce({ ...mockUser, displayName: 'Name 2' }); + + const { result } = renderHook(() => useProfileSettings()); + + let results: (User | null)[] = []; + await act(async () => { + const promises = [ + result.current.updateProfile({ displayName: 'Name 1' }), + result.current.updateProfile({ displayName: 'Name 2' }), + ]; + results = await Promise.all(promises); + }); + + expect(results).toHaveLength(2); + expect(results[0]?.displayName).toBe('Name 1'); + expect(results[1]?.displayName).toBe('Name 2'); + expect(mockUpdateProfile).toHaveBeenCalledTimes(2); + }); + + it('handles update followed by delete', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + const mockDeleteUser = vi.mocked(userApi.deleteUser); + + mockUpdateProfile.mockResolvedValue(mockUser); + mockDeleteUser.mockResolvedValue(undefined); + + const { result } = renderHook(() => useProfileSettings()); + + let updateResult: User | null = null; + let deleteResult: boolean | undefined; + + await act(async () => { + updateResult = await result.current.updateProfile({ + displayName: 'Updated', + }); + }); + + await act(async () => { + deleteResult = await result.current.deleteAccount('password123'); + }); + + expect(updateResult).toEqual(mockUser); + expect(deleteResult).toBe(true); + expect(mockUpdateProfile).toHaveBeenCalledWith({ + displayName: 'Updated', + }); + expect(mockDeleteUser).toHaveBeenCalledWith('password123'); + }); + }); + + describe('hook interface', () => { + it('returns correct interface', () => { + const { result } = renderHook(() => useProfileSettings()); + + expect(typeof result.current.loading).toBe('boolean'); + expect(typeof result.current.updateProfile).toBe('function'); + expect(typeof result.current.deleteAccount).toBe('function'); + }); + + it('functions are stable across re-renders', () => { + const { result, rerender } = renderHook(() => useProfileSettings()); + + const initialFunctions = { + updateProfile: result.current.updateProfile, + deleteAccount: result.current.deleteAccount, + }; + + rerender(); + + expect(result.current.updateProfile).toBe(initialFunctions.updateProfile); + expect(result.current.deleteAccount).toBe(initialFunctions.deleteAccount); + }); + }); + + describe('edge cases', () => { + it('handles empty update request', async () => { + const mockUpdateProfile = vi.mocked(userApi.updateProfile); + mockUpdateProfile.mockResolvedValue(mockUser); + + const { result } = renderHook(() => useProfileSettings()); + + let returnedUser: User | null = null; + await act(async () => { + returnedUser = await result.current.updateProfile({}); + }); + + expect(returnedUser).toEqual(mockUser); + expect(mockUpdateProfile).toHaveBeenCalledWith({}); + }); + }); +}); diff --git a/app/src/hooks/useUserAdmin.test.ts b/app/src/hooks/useUserAdmin.test.ts new file mode 100644 index 0000000..80c7103 --- /dev/null +++ b/app/src/hooks/useUserAdmin.test.ts @@ -0,0 +1,610 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useUserAdmin } from './useUserAdmin'; +import * as adminApi from '@/api/admin'; +import type { CreateUserRequest, UpdateUserRequest } from '@/types/api'; +import { UserRole, type User } from '@/types/models'; + +// Mock dependencies +vi.mock('@/api/admin'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock useAdminData hook +const mockAdminData = { + data: [] as User[], + loading: false, + error: null as string | null, + reload: vi.fn(), +}; + +vi.mock('./useAdminData', () => ({ + useAdminData: () => mockAdminData, +})); + +// Import notifications for assertions +import { notifications } from '@mantine/notifications'; + +// Mock user data +const mockUsers: User[] = [ + { + id: 1, + email: 'admin@example.com', + displayName: 'Admin User', + role: UserRole.Admin, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }, + { + id: 2, + email: 'editor@example.com', + displayName: 'Editor User', + role: UserRole.Editor, + createdAt: '2024-01-02T00:00:00Z', + lastWorkspaceId: 1, + }, +]; + +// Helper function to get a user by index and ensure it's not undefined +const getUser = (index: number): User => { + const user = mockUsers[index]; + if (!user) { + throw new Error(`User at index ${index} is undefined`); + } + return user; +}; + +describe('useUserAdmin', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock data + mockAdminData.data = [...mockUsers]; + mockAdminData.loading = false; + mockAdminData.error = null; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('returns users data from useAdminData', () => { + const { result } = renderHook(() => useUserAdmin()); + + expect(result.current.users).toEqual(mockUsers); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('returns loading state from useAdminData', () => { + mockAdminData.loading = true; + + const { result } = renderHook(() => useUserAdmin()); + + expect(result.current.loading).toBe(true); + }); + + it('returns error state from useAdminData', () => { + mockAdminData.error = 'Failed to load users'; + + const { result } = renderHook(() => useUserAdmin()); + + expect(result.current.error).toBe('Failed to load users'); + }); + + it('provides CRUD functions', () => { + const { result } = renderHook(() => useUserAdmin()); + + expect(typeof result.current.create).toBe('function'); + expect(typeof result.current.update).toBe('function'); + expect(typeof result.current.delete).toBe('function'); + }); + }); + + describe('create user', () => { + it('creates user successfully', async () => { + const mockCreateUser = vi.mocked(adminApi.createUser); + const newUser: User = { + id: 3, + email: 'newuser@example.com', + displayName: 'New User', + role: UserRole.Viewer, + createdAt: '2024-01-03T00:00:00Z', + lastWorkspaceId: 1, + }; + mockCreateUser.mockResolvedValue(newUser); + + const { result } = renderHook(() => useUserAdmin()); + + const createRequest: CreateUserRequest = { + email: 'newuser@example.com', + displayName: 'New User', + password: 'password123', + role: UserRole.Viewer, + }; + + let createResult: boolean | undefined; + await act(async () => { + createResult = await result.current.create(createRequest); + }); + + expect(createResult).toBe(true); + expect(mockCreateUser).toHaveBeenCalledWith(createRequest); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'User created successfully', + color: 'green', + }); + expect(mockAdminData.reload).toHaveBeenCalled(); + }); + + it('handles create errors with specific message', async () => { + const mockCreateUser = vi.mocked(adminApi.createUser); + mockCreateUser.mockRejectedValue(new Error('Email already exists')); + + const { result } = renderHook(() => useUserAdmin()); + + const createRequest: CreateUserRequest = { + email: 'existing@example.com', + displayName: 'Test User', + password: 'password123', + role: UserRole.Editor, + }; + + let createResult: boolean | undefined; + await act(async () => { + createResult = await result.current.create(createRequest); + }); + + expect(createResult).toBe(false); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to create user: Email already exists', + color: 'red', + }); + expect(mockAdminData.reload).not.toHaveBeenCalled(); + }); + + it('handles create errors with non-Error rejection', async () => { + const mockCreateUser = vi.mocked(adminApi.createUser); + mockCreateUser.mockRejectedValue('String error'); + + const { result } = renderHook(() => useUserAdmin()); + + const createRequest: CreateUserRequest = { + email: 'test@example.com', + displayName: 'Test User', + password: 'password123', + role: UserRole.Editor, + }; + + let createResult: boolean | undefined; + await act(async () => { + createResult = await result.current.create(createRequest); + }); + + expect(createResult).toBe(false); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to create user: String error', + color: 'red', + }); + }); + }); + + describe('update user', () => { + it('updates user successfully', async () => { + const mockUpdateUser = vi.mocked(adminApi.updateUser); + const user = getUser(1); + const updatedUser: User = { + id: user.id, + email: user.email, + displayName: 'Updated Editor', + role: user.role, + createdAt: user.createdAt, + lastWorkspaceId: user.lastWorkspaceId, + }; + mockUpdateUser.mockResolvedValue(updatedUser); + + const { result } = renderHook(() => useUserAdmin()); + + const updateRequest: UpdateUserRequest = { + displayName: 'Updated Editor', + }; + + let updateResult: boolean | undefined; + await act(async () => { + updateResult = await result.current.update(2, updateRequest); + }); + + expect(updateResult).toBe(true); + expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'User updated successfully', + color: 'green', + }); + expect(mockAdminData.reload).toHaveBeenCalled(); + }); + + it('updates user email and role', async () => { + const mockUpdateUser = vi.mocked(adminApi.updateUser); + const user = getUser(1); + const updatedUser: User = { + id: user.id, + email: 'newemail@example.com', + displayName: user.displayName || '', + role: UserRole.Admin, + createdAt: user.createdAt, + lastWorkspaceId: user.lastWorkspaceId, + }; + mockUpdateUser.mockResolvedValue(updatedUser); + + const { result } = renderHook(() => useUserAdmin()); + + const updateRequest: UpdateUserRequest = { + email: 'newemail@example.com', + role: UserRole.Admin, + }; + + let updateResult: boolean | undefined; + await act(async () => { + updateResult = await result.current.update(2, updateRequest); + }); + + expect(updateResult).toBe(true); + expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest); + }); + + it('updates user password', async () => { + const mockUpdateUser = vi.mocked(adminApi.updateUser); + mockUpdateUser.mockResolvedValue(getUser(1)); + + const { result } = renderHook(() => useUserAdmin()); + + const updateRequest: UpdateUserRequest = { + password: 'newpassword123', + }; + + let updateResult: boolean | undefined; + await act(async () => { + updateResult = await result.current.update(2, updateRequest); + }); + + expect(updateResult).toBe(true); + expect(mockUpdateUser).toHaveBeenCalledWith(2, updateRequest); + }); + + it('handles update errors', async () => { + const mockUpdateUser = vi.mocked(adminApi.updateUser); + mockUpdateUser.mockRejectedValue(new Error('User not found')); + + const { result } = renderHook(() => useUserAdmin()); + + const updateRequest: UpdateUserRequest = { + displayName: 'Updated Name', + }; + + let updateResult: boolean | undefined; + await act(async () => { + updateResult = await result.current.update(999, updateRequest); + }); + + expect(updateResult).toBe(false); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to update user: User not found', + color: 'red', + }); + expect(mockAdminData.reload).not.toHaveBeenCalled(); + }); + + it('handles empty update request', async () => { + const mockUpdateUser = vi.mocked(adminApi.updateUser); + mockUpdateUser.mockResolvedValue(getUser(1)); + + const { result } = renderHook(() => useUserAdmin()); + + let updateResult: boolean | undefined; + await act(async () => { + updateResult = await result.current.update(2, {}); + }); + + expect(updateResult).toBe(true); + expect(mockUpdateUser).toHaveBeenCalledWith(2, {}); + }); + }); + + describe('delete user', () => { + it('deletes user successfully', async () => { + const mockDeleteUser = vi.mocked(adminApi.deleteUser); + mockDeleteUser.mockResolvedValue(undefined); + + const { result } = renderHook(() => useUserAdmin()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.delete(2); + }); + + expect(deleteResult).toBe(true); + expect(mockDeleteUser).toHaveBeenCalledWith(2); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'User deleted successfully', + color: 'green', + }); + expect(mockAdminData.reload).toHaveBeenCalled(); + }); + + it('handles delete errors', async () => { + const mockDeleteUser = vi.mocked(adminApi.deleteUser); + mockDeleteUser.mockRejectedValue(new Error('Cannot delete admin user')); + + const { result } = renderHook(() => useUserAdmin()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.delete(1); + }); + + expect(deleteResult).toBe(false); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to delete user: Cannot delete admin user', + color: 'red', + }); + expect(mockAdminData.reload).not.toHaveBeenCalled(); + }); + + it('handles delete with non-existent user', async () => { + const mockDeleteUser = vi.mocked(adminApi.deleteUser); + mockDeleteUser.mockRejectedValue(new Error('User not found')); + + const { result } = renderHook(() => useUserAdmin()); + + let deleteResult: boolean | undefined; + await act(async () => { + deleteResult = await result.current.delete(999); + }); + + expect(deleteResult).toBe(false); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to delete user: User not found', + color: 'red', + }); + }); + }); + + describe('data integration', () => { + it('reflects loading state changes', () => { + const { result, rerender } = renderHook(() => useUserAdmin()); + + expect(result.current.loading).toBe(false); + + // Change loading state + mockAdminData.loading = true; + rerender(); + + expect(result.current.loading).toBe(true); + }); + + it('reflects error state changes', () => { + const { result, rerender } = renderHook(() => useUserAdmin()); + + expect(result.current.error).toBeNull(); + + // Add error + mockAdminData.error = 'Network error'; + rerender(); + + expect(result.current.error).toBe('Network error'); + }); + + it('reflects data changes', () => { + const { result, rerender } = renderHook(() => useUserAdmin()); + + expect(result.current.users).toEqual(mockUsers); + + // Change users data + const newUsers = [mockUsers[0]].filter((u): u is User => u !== undefined); + mockAdminData.data = newUsers; + rerender(); + + expect(result.current.users).toEqual(newUsers); + }); + + it('calls reload after successful operations', async () => { + const mockCreateUser = vi.mocked(adminApi.createUser); + const mockUpdateUser = vi.mocked(adminApi.updateUser); + const mockDeleteUser = vi.mocked(adminApi.deleteUser); + + mockCreateUser.mockResolvedValue(getUser(0)); + mockUpdateUser.mockResolvedValue(getUser(0)); + mockDeleteUser.mockResolvedValue(undefined); + + const { result } = renderHook(() => useUserAdmin()); + + // Test create + await act(async () => { + await result.current.create({ + email: 'test@example.com', + displayName: 'Test', + password: 'pass', + role: UserRole.Viewer, + }); + }); + + expect(mockAdminData.reload).toHaveBeenCalledTimes(1); + + // Test update + await act(async () => { + await result.current.update(1, { displayName: 'Updated' }); + }); + + expect(mockAdminData.reload).toHaveBeenCalledTimes(2); + + // Test delete + await act(async () => { + await result.current.delete(1); + }); + + expect(mockAdminData.reload).toHaveBeenCalledTimes(3); + }); + + it('does not call reload after failed operations', async () => { + const mockCreateUser = vi.mocked(adminApi.createUser); + const mockUpdateUser = vi.mocked(adminApi.updateUser); + const mockDeleteUser = vi.mocked(adminApi.deleteUser); + + mockCreateUser.mockRejectedValue(new Error('Create failed')); + mockUpdateUser.mockRejectedValue(new Error('Update failed')); + mockDeleteUser.mockRejectedValue(new Error('Delete failed')); + + const { result } = renderHook(() => useUserAdmin()); + + // Test failed create + await act(async () => { + await result.current.create({ + email: 'test@example.com', + displayName: 'Test', + password: 'pass', + role: UserRole.Viewer, + }); + }); + + // Test failed update + await act(async () => { + await result.current.update(1, { displayName: 'Updated' }); + }); + + // Test failed delete + await act(async () => { + await result.current.delete(1); + }); + + expect(mockAdminData.reload).not.toHaveBeenCalled(); + }); + }); + + describe('concurrent operations', () => { + it('handles multiple create operations', async () => { + const mockCreateUser = vi.mocked(adminApi.createUser); + mockCreateUser + .mockResolvedValueOnce({ + id: 3, + email: 'user1@example.com', + displayName: 'User 1', + role: UserRole.Viewer, + createdAt: '2024-01-03T00:00:00Z', + lastWorkspaceId: 1, + }) + .mockResolvedValueOnce({ + id: 4, + email: 'user2@example.com', + displayName: 'User 2', + role: UserRole.Editor, + createdAt: '2024-01-04T00:00:00Z', + lastWorkspaceId: 1, + }); + + const { result } = renderHook(() => useUserAdmin()); + + const requests = [ + { + email: 'user1@example.com', + displayName: 'User 1', + password: 'pass1', + role: UserRole.Viewer, + }, + { + email: 'user2@example.com', + displayName: 'User 2', + password: 'pass2', + role: UserRole.Editor, + }, + ]; + + let results: boolean[] = []; + await act(async () => { + results = await Promise.all( + requests.map((req) => result.current.create(req)) + ); + }); + + expect(results).toEqual([true, true]); + expect(mockCreateUser).toHaveBeenCalledTimes(2); + expect(mockAdminData.reload).toHaveBeenCalledTimes(2); + }); + + it('handles mixed successful and failed operations', async () => { + const mockCreateUser = vi.mocked(adminApi.createUser); + mockCreateUser + .mockResolvedValueOnce(getUser(0)) + .mockRejectedValueOnce(new Error('Second create failed')); + + const { result } = renderHook(() => useUserAdmin()); + + const requests = [ + { + email: 'success@example.com', + displayName: 'Success User', + password: 'pass1', + role: UserRole.Viewer, + }, + { + email: 'fail@example.com', + displayName: 'Fail User', + password: 'pass2', + role: UserRole.Editor, + }, + ]; + + let results: boolean[] = []; + await act(async () => { + results = await Promise.all( + requests.map((req) => result.current.create(req)) + ); + }); + + expect(results).toEqual([true, false]); + expect(mockAdminData.reload).toHaveBeenCalledTimes(1); // Only for successful operation + }); + }); + + describe('hook interface stability', () => { + it('functions are stable across re-renders', () => { + const { result, rerender } = renderHook(() => useUserAdmin()); + + const initialFunctions = { + create: result.current.create, + update: result.current.update, + delete: result.current.delete, + }; + + rerender(); + + expect(result.current.create).toBe(initialFunctions.create); + expect(result.current.update).toBe(initialFunctions.update); + expect(result.current.delete).toBe(initialFunctions.delete); + }); + + it('returns consistent interface', () => { + const { result } = renderHook(() => useUserAdmin()); + + expect(Array.isArray(result.current.users)).toBe(true); + expect(typeof result.current.loading).toBe('boolean'); + expect( + result.current.error === null || + typeof result.current.error === 'string' + ).toBe(true); + expect(typeof result.current.create).toBe('function'); + expect(typeof result.current.update).toBe('function'); + expect(typeof result.current.delete).toBe('function'); + }); + }); +}); diff --git a/app/src/hooks/useUserAdmin.ts b/app/src/hooks/useUserAdmin.ts index 9258eb0..07e5763 100644 --- a/app/src/hooks/useUserAdmin.ts +++ b/app/src/hooks/useUserAdmin.ts @@ -5,6 +5,7 @@ import { deleteUser as adminDeleteUser, } from '../api/admin'; import { notifications } from '@mantine/notifications'; +import { useCallback } from 'react'; import type { User } from '@/types/models'; import type { CreateUserRequest, UpdateUserRequest } from '@/types/api'; @@ -20,73 +21,77 @@ interface UseUserAdminResult { export const useUserAdmin = (): UseUserAdminResult => { const { data: users, loading, error, reload } = useAdminData('users'); - const handleCreate = async ( - userData: CreateUserRequest - ): Promise => { - try { - await createUser(userData); - notifications.show({ - title: 'Success', - message: 'User created successfully', - color: 'green', - }); - await reload(); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - notifications.show({ - title: 'Error', - message: `Failed to create user: ${message}`, - color: 'red', - }); - return false; - } - }; + const handleCreate = useCallback( + async (userData: CreateUserRequest): Promise => { + try { + await createUser(userData); + notifications.show({ + title: 'Success', + message: 'User created successfully', + color: 'green', + }); + await reload(); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + notifications.show({ + title: 'Error', + message: `Failed to create user: ${message}`, + color: 'red', + }); + return false; + } + }, + [reload] + ); - const handleUpdate = async ( - userId: number, - userData: UpdateUserRequest - ): Promise => { - try { - await updateUser(userId, userData); - notifications.show({ - title: 'Success', - message: 'User updated successfully', - color: 'green', - }); - await reload(); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - notifications.show({ - title: 'Error', - message: `Failed to update user: ${message}`, - color: 'red', - }); - return false; - } - }; + const handleUpdate = useCallback( + async (userId: number, userData: UpdateUserRequest): Promise => { + try { + await updateUser(userId, userData); + notifications.show({ + title: 'Success', + message: 'User updated successfully', + color: 'green', + }); + await reload(); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + notifications.show({ + title: 'Error', + message: `Failed to update user: ${message}`, + color: 'red', + }); + return false; + } + }, + [reload] + ); - const handleDelete = async (userId: number): Promise => { - try { - await adminDeleteUser(userId); - notifications.show({ - title: 'Success', - message: 'User deleted successfully', - color: 'green', - }); - await reload(); - return true; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - notifications.show({ - title: 'Error', - message: `Failed to delete user: ${message}`, - color: 'red', - }); - return false; - } - }; + const handleDelete = useCallback( + async (userId: number): Promise => { + try { + await adminDeleteUser(userId); + notifications.show({ + title: 'Success', + message: 'User deleted successfully', + color: 'green', + }); + await reload(); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + notifications.show({ + title: 'Error', + message: `Failed to delete user: ${message}`, + color: 'red', + }); + return false; + } + }, + [reload] + ); return { users, diff --git a/app/src/hooks/useWorkspace.test.ts b/app/src/hooks/useWorkspace.test.ts new file mode 100644 index 0000000..2ff39ac --- /dev/null +++ b/app/src/hooks/useWorkspace.test.ts @@ -0,0 +1,491 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useWorkspace } from './useWorkspace'; +import { + Theme, + type Workspace, + DEFAULT_WORKSPACE_SETTINGS, +} from '@/types/models'; +import type { MantineColorScheme } from '@mantine/core'; + +// Mock the constituent hooks +const mockWorkspaceData = { + currentWorkspace: null as Workspace | null, + workspaces: [] as Workspace[], + settings: DEFAULT_WORKSPACE_SETTINGS, + loading: false, +}; + +const mockTheme = { + colorScheme: 'light' as MantineColorScheme, + updateColorScheme: vi.fn(), +}; + +const mockWorkspaceOperations = { + switchWorkspace: vi.fn(), + deleteCurrentWorkspace: vi.fn(), + updateSettings: vi.fn(), +}; + +vi.mock('../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => mockWorkspaceData, +})); + +vi.mock('../contexts/ThemeContext', () => ({ + useTheme: () => mockTheme, +})); + +vi.mock('./useWorkspaceOperations', () => ({ + useWorkspaceOperations: () => mockWorkspaceOperations, +})); + +// Mock workspace data +const mockWorkspace: Workspace = { + id: 1, + userId: 1, + name: 'test-workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', +}; + +const mockWorkspaces: Workspace[] = [ + mockWorkspace, + { + id: 2, + userId: 1, + name: 'second-workspace', + createdAt: '2024-01-02T00:00:00Z', + theme: Theme.Dark, + autoSave: true, + showHiddenFiles: true, + gitEnabled: true, + gitUrl: 'https://github.com/user/repo.git', + gitUser: 'user', + gitToken: 'token', + gitAutoCommit: true, + gitCommitMsgTemplate: 'auto: ${action} ${filename}', + gitCommitName: 'Test User', + gitCommitEmail: 'test@example.com', + }, +]; + +describe('useWorkspace', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock data to defaults + mockWorkspaceData.currentWorkspace = null; + mockWorkspaceData.workspaces = []; + mockWorkspaceData.loading = false; + mockTheme.colorScheme = 'light'; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initial state', () => { + it('returns default values when no workspace is loaded', () => { + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.currentWorkspace).toBeNull(); + expect(result.current.workspaces).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.colorScheme).toBe('light'); + }); + + it('provides all expected functions', () => { + const { result } = renderHook(() => useWorkspace()); + + expect(typeof result.current.updateSettings).toBe('function'); + expect(typeof result.current.updateColorScheme).toBe('function'); + expect(typeof result.current.switchWorkspace).toBe('function'); + expect(typeof result.current.deleteCurrentWorkspace).toBe('function'); + }); + }); + + describe('workspace data integration', () => { + it('returns current workspace data', () => { + mockWorkspaceData.currentWorkspace = mockWorkspace; + mockWorkspaceData.workspaces = mockWorkspaces; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(result.current.workspaces).toEqual(mockWorkspaces); + }); + + it('returns loading state from workspace data', () => { + mockWorkspaceData.loading = true; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.loading).toBe(true); + }); + }); + + describe('theme integration', () => { + it('returns color scheme from theme context', () => { + mockTheme.colorScheme = 'dark'; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.colorScheme).toBe('dark'); + }); + + it('provides updateColorScheme function from theme context', () => { + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.updateColorScheme).toBe( + mockTheme.updateColorScheme + ); + }); + }); + + describe('workspace operations integration', () => { + it('provides switchWorkspace function from operations', () => { + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.switchWorkspace).toBe( + mockWorkspaceOperations.switchWorkspace + ); + }); + + it('provides deleteCurrentWorkspace function from operations', () => { + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.deleteCurrentWorkspace).toBe( + mockWorkspaceOperations.deleteCurrentWorkspace + ); + }); + + it('provides updateSettings function from operations', () => { + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.updateSettings).toBe( + mockWorkspaceOperations.updateSettings + ); + }); + }); + + describe('data consistency', () => { + it('returns consistent data across multiple renders', () => { + mockWorkspaceData.currentWorkspace = mockWorkspace; + mockWorkspaceData.workspaces = mockWorkspaces; + mockTheme.colorScheme = 'dark'; + + const { result, rerender } = renderHook(() => useWorkspace()); + + const firstResult = { ...result.current }; + + rerender(); + + expect(result.current.currentWorkspace).toEqual( + firstResult.currentWorkspace + ); + expect(result.current.workspaces).toEqual(firstResult.workspaces); + expect(result.current.colorScheme).toEqual(firstResult.colorScheme); + }); + + it('reflects changes in underlying data', () => { + const { result, rerender } = renderHook(() => useWorkspace()); + + // Initially no workspace + expect(result.current.currentWorkspace).toBeNull(); + expect(result.current.workspaces).toEqual([]); + + // Add workspace data + mockWorkspaceData.currentWorkspace = mockWorkspace; + mockWorkspaceData.workspaces = mockWorkspaces; + + rerender(); + + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(result.current.workspaces).toEqual(mockWorkspaces); + }); + + it('reflects theme changes', () => { + const { result, rerender } = renderHook(() => useWorkspace()); + + // Initially light theme + expect(result.current.colorScheme).toBe('light'); + + // Change to dark theme + mockTheme.colorScheme = 'dark'; + + rerender(); + + expect(result.current.colorScheme).toBe('dark'); + }); + + it('reflects loading state changes', () => { + const { result, rerender } = renderHook(() => useWorkspace()); + + // Initially not loading + expect(result.current.loading).toBe(false); + + // Change to loading + mockWorkspaceData.loading = true; + + rerender(); + + expect(result.current.loading).toBe(true); + }); + }); + + describe('function stability', () => { + it('maintains stable function references across re-renders', () => { + const { result, rerender } = renderHook(() => useWorkspace()); + + const initialFunctions = { + updateSettings: result.current.updateSettings, + updateColorScheme: result.current.updateColorScheme, + switchWorkspace: result.current.switchWorkspace, + deleteCurrentWorkspace: result.current.deleteCurrentWorkspace, + }; + + rerender(); + + expect(result.current.updateSettings).toBe( + initialFunctions.updateSettings + ); + expect(result.current.updateColorScheme).toBe( + initialFunctions.updateColorScheme + ); + expect(result.current.switchWorkspace).toBe( + initialFunctions.switchWorkspace + ); + expect(result.current.deleteCurrentWorkspace).toBe( + initialFunctions.deleteCurrentWorkspace + ); + }); + + it('maintains stable function references when data changes', () => { + const { result, rerender } = renderHook(() => useWorkspace()); + + const initialFunctions = { + updateSettings: result.current.updateSettings, + updateColorScheme: result.current.updateColorScheme, + switchWorkspace: result.current.switchWorkspace, + deleteCurrentWorkspace: result.current.deleteCurrentWorkspace, + }; + + // Change data + mockWorkspaceData.currentWorkspace = mockWorkspace; + mockWorkspaceData.workspaces = mockWorkspaces; + mockTheme.colorScheme = 'dark'; + + rerender(); + + expect(result.current.updateSettings).toBe( + initialFunctions.updateSettings + ); + expect(result.current.updateColorScheme).toBe( + initialFunctions.updateColorScheme + ); + expect(result.current.switchWorkspace).toBe( + initialFunctions.switchWorkspace + ); + expect(result.current.deleteCurrentWorkspace).toBe( + initialFunctions.deleteCurrentWorkspace + ); + }); + }); + + describe('hook interface', () => { + it('returns correct interface structure', () => { + const { result } = renderHook(() => useWorkspace()); + + const expectedKeys = [ + 'currentWorkspace', + 'workspaces', + 'updateSettings', + 'loading', + 'colorScheme', + 'updateColorScheme', + 'switchWorkspace', + 'deleteCurrentWorkspace', + ]; + + expectedKeys.forEach((key) => { + expect(key in result.current).toBe(true); + }); + }); + + it('returns correct types for all properties', () => { + const { result } = renderHook(() => useWorkspace()); + + expect( + result.current.currentWorkspace === null || + typeof result.current.currentWorkspace === 'object' + ).toBe(true); + expect(Array.isArray(result.current.workspaces)).toBe(true); + expect(typeof result.current.updateSettings === 'function').toBe(true); + expect(typeof result.current.loading === 'boolean').toBe(true); + expect(typeof result.current.colorScheme === 'string').toBe(true); + expect(typeof result.current.updateColorScheme === 'function').toBe(true); + expect(typeof result.current.switchWorkspace === 'function').toBe(true); + expect(typeof result.current.deleteCurrentWorkspace === 'function').toBe( + true + ); + }); + }); + + describe('edge cases', () => { + it('handles undefined workspace data gracefully', () => { + // Simulate undefined data that might occur during loading + mockWorkspaceData.currentWorkspace = null; + mockWorkspaceData.workspaces = []; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.currentWorkspace).toBeNull(); + expect(result.current.workspaces).toEqual([]); + expect(typeof result.current.updateSettings).toBe('function'); + }); + + it('handles empty workspaces array', () => { + mockWorkspaceData.workspaces = []; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.workspaces).toEqual([]); + }); + + it('handles single workspace', () => { + const singleWorkspace = [mockWorkspace]; + mockWorkspaceData.workspaces = singleWorkspace; + mockWorkspaceData.currentWorkspace = mockWorkspace; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.workspaces).toEqual(singleWorkspace); + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + }); + + it('handles workspace with minimal data', () => { + const minimalWorkspace: Workspace = { + name: 'minimal', + createdAt: Date.now(), + ...DEFAULT_WORKSPACE_SETTINGS, + }; + + mockWorkspaceData.currentWorkspace = minimalWorkspace; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.currentWorkspace).toEqual(minimalWorkspace); + }); + }); + + describe('integration scenarios', () => { + it('provides complete workspace management interface', () => { + mockWorkspaceData.currentWorkspace = mockWorkspace; + mockWorkspaceData.workspaces = mockWorkspaces; + mockTheme.colorScheme = 'light'; + + const { result } = renderHook(() => useWorkspace()); + + // Should have all data + expect(result.current.currentWorkspace).toEqual(mockWorkspace); + expect(result.current.workspaces).toEqual(mockWorkspaces); + expect(result.current.colorScheme).toBe('light'); + + // Should have all operations + expect(typeof result.current.updateSettings).toBe('function'); + expect(typeof result.current.switchWorkspace).toBe('function'); + expect(typeof result.current.deleteCurrentWorkspace).toBe('function'); + expect(typeof result.current.updateColorScheme).toBe('function'); + }); + + it('supports workspace switching workflow', () => { + const { result } = renderHook(() => useWorkspace()); + + // Initially no workspace + expect(result.current.currentWorkspace).toBeNull(); + + // Should provide switch function + expect(typeof result.current.switchWorkspace).toBe('function'); + expect(result.current.switchWorkspace).toBe( + mockWorkspaceOperations.switchWorkspace + ); + }); + + it('supports settings management workflow', () => { + mockWorkspaceData.currentWorkspace = mockWorkspace; + + const { result } = renderHook(() => useWorkspace()); + + // Should provide update function + expect(typeof result.current.updateSettings).toBe('function'); + expect(result.current.updateSettings).toBe( + mockWorkspaceOperations.updateSettings + ); + }); + + it('supports theme management workflow', () => { + mockTheme.colorScheme = 'dark'; + + const { result } = renderHook(() => useWorkspace()); + + // Should have current color scheme + expect(result.current.colorScheme).toBe('dark'); + + // Should provide update function + expect(typeof result.current.updateColorScheme).toBe('function'); + expect(result.current.updateColorScheme).toBe( + mockTheme.updateColorScheme + ); + }); + }); + + describe('mock integration validation', () => { + it('correctly integrates with WorkspaceDataContext mock', () => { + mockWorkspaceData.currentWorkspace = mockWorkspace; + mockWorkspaceData.workspaces = mockWorkspaces; + mockWorkspaceData.loading = true; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.currentWorkspace).toBe( + mockWorkspaceData.currentWorkspace + ); + expect(result.current.workspaces).toBe(mockWorkspaceData.workspaces); + expect(result.current.loading).toBe(mockWorkspaceData.loading); + }); + + it('correctly integrates with ThemeContext mock', () => { + mockTheme.colorScheme = 'dark'; + + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.colorScheme).toBe(mockTheme.colorScheme); + expect(result.current.updateColorScheme).toBe( + mockTheme.updateColorScheme + ); + }); + + it('correctly integrates with useWorkspaceOperations mock', () => { + const { result } = renderHook(() => useWorkspace()); + + expect(result.current.switchWorkspace).toBe( + mockWorkspaceOperations.switchWorkspace + ); + expect(result.current.deleteCurrentWorkspace).toBe( + mockWorkspaceOperations.deleteCurrentWorkspace + ); + expect(result.current.updateSettings).toBe( + mockWorkspaceOperations.updateSettings + ); + }); + }); +}); diff --git a/app/src/hooks/useWorkspace.ts b/app/src/hooks/useWorkspace.ts index fcaba6c..4b60849 100644 --- a/app/src/hooks/useWorkspace.ts +++ b/app/src/hooks/useWorkspace.ts @@ -1,13 +1,12 @@ import { useWorkspaceData } from '../contexts/WorkspaceDataContext'; import { useTheme } from '../contexts/ThemeContext'; import { useWorkspaceOperations } from './useWorkspaceOperations'; -import type { Workspace, DEFAULT_WORKSPACE_SETTINGS } from '@/types/models'; +import type { Workspace } from '@/types/models'; import type { MantineColorScheme } from '@mantine/core'; interface UseWorkspaceResult { currentWorkspace: Workspace | null; workspaces: Workspace[]; - settings: Workspace | typeof DEFAULT_WORKSPACE_SETTINGS; updateSettings: (newSettings: Partial) => Promise; loading: boolean; colorScheme: MantineColorScheme; @@ -17,8 +16,7 @@ interface UseWorkspaceResult { } export const useWorkspace = (): UseWorkspaceResult => { - const { currentWorkspace, workspaces, settings, loading } = - useWorkspaceData(); + const { currentWorkspace, workspaces, loading } = useWorkspaceData(); const { colorScheme, updateColorScheme } = useTheme(); const { switchWorkspace, deleteCurrentWorkspace, updateSettings } = useWorkspaceOperations(); @@ -26,7 +24,6 @@ export const useWorkspace = (): UseWorkspaceResult => { return { currentWorkspace, workspaces, - settings, updateSettings, loading, colorScheme, diff --git a/app/src/hooks/useWorkspaceOperations.test.ts b/app/src/hooks/useWorkspaceOperations.test.ts new file mode 100644 index 0000000..3999579 --- /dev/null +++ b/app/src/hooks/useWorkspaceOperations.test.ts @@ -0,0 +1,575 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useWorkspaceOperations } from './useWorkspaceOperations'; +import * as workspaceApi from '@/api/workspace'; +import { Theme, type Workspace } from '@/types/models'; + +// Mock dependencies +vi.mock('@/api/workspace'); +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock workspace data context +const mockWorkspaceData: { + currentWorkspace: Workspace | null; + loadWorkspaceData: ReturnType; + loadWorkspaces: ReturnType; + setCurrentWorkspace: ReturnType; +} = { + currentWorkspace: { + id: 1, + userId: 1, + name: 'test-workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }, + loadWorkspaceData: vi.fn(), + loadWorkspaces: vi.fn(), + setCurrentWorkspace: vi.fn(), +}; + +// Mock theme context +const mockTheme = { + updateColorScheme: vi.fn(), +}; + +vi.mock('../contexts/WorkspaceDataContext', () => ({ + useWorkspaceData: () => mockWorkspaceData, +})); + +vi.mock('../contexts/ThemeContext', () => ({ + useTheme: () => mockTheme, +})); + +// Import notifications for assertions +import { notifications } from '@mantine/notifications'; + +// Mock workspaces for testing +const mockWorkspaces: Workspace[] = [ + { + id: 1, + userId: 1, + name: 'workspace-1', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }, + { + id: 2, + userId: 1, + name: 'workspace-2', + createdAt: '2024-01-02T00:00:00Z', + theme: Theme.Dark, + autoSave: true, + showHiddenFiles: true, + gitEnabled: true, + gitUrl: 'https://github.com/user/repo.git', + gitUser: 'user', + gitToken: 'token', + gitAutoCommit: true, + gitCommitMsgTemplate: 'auto: ${action} ${filename}', + gitCommitName: 'Test User', + gitCommitEmail: 'test@example.com', + }, +]; + +describe('useWorkspaceOperations', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset workspace data to defaults + mockWorkspaceData.currentWorkspace = { + id: 1, + userId: 1, + name: 'test-workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('switchWorkspace', () => { + it('switches workspace successfully', async () => { + const mockUpdateLastWorkspaceName = vi.mocked( + workspaceApi.updateLastWorkspaceName + ); + mockUpdateLastWorkspaceName.mockResolvedValue(undefined); + mockWorkspaceData.loadWorkspaceData.mockResolvedValue(undefined); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.switchWorkspace('new-workspace'); + }); + + expect(mockUpdateLastWorkspaceName).toHaveBeenCalledWith('new-workspace'); + expect(mockWorkspaceData.loadWorkspaceData).toHaveBeenCalledWith( + 'new-workspace' + ); + expect(mockWorkspaceData.loadWorkspaces).toHaveBeenCalled(); + }); + + it('handles switch workspace errors', async () => { + const mockUpdateLastWorkspaceName = vi.mocked( + workspaceApi.updateLastWorkspaceName + ); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockUpdateLastWorkspaceName.mockRejectedValue(new Error('Switch failed')); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.switchWorkspace('error-workspace'); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to switch workspace:', + expect.any(Error) + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to switch workspace', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + + it('handles load workspace data errors during switch', async () => { + const mockUpdateLastWorkspaceName = vi.mocked( + workspaceApi.updateLastWorkspaceName + ); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockUpdateLastWorkspaceName.mockResolvedValue(undefined); + mockWorkspaceData.loadWorkspaceData.mockRejectedValue( + new Error('Load failed') + ); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.switchWorkspace('error-workspace'); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to switch workspace:', + expect.any(Error) + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to switch workspace', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('deleteCurrentWorkspace', () => { + it('deletes workspace successfully when multiple workspaces exist', async () => { + const mockDeleteWorkspace = vi.mocked(workspaceApi.deleteWorkspace); + mockDeleteWorkspace.mockResolvedValue('next-workspace'); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + mockWorkspaceData.loadWorkspaceData.mockResolvedValue(undefined); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.deleteCurrentWorkspace(); + }); + + expect(mockWorkspaceData.loadWorkspaces).toHaveBeenCalledTimes(2); // Once for check, once after deletion + expect(mockDeleteWorkspace).toHaveBeenCalledWith('test-workspace'); + expect(mockWorkspaceData.loadWorkspaceData).toHaveBeenCalledWith( + 'next-workspace' + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Success', + message: 'Workspace deleted successfully', + color: 'green', + }); + }); + + it('prevents deletion when only one workspace exists', async () => { + const singleWorkspace = [mockWorkspaces[0]].filter( + (w): w is Workspace => w !== undefined + ); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(singleWorkspace); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.deleteCurrentWorkspace(); + }); + + expect(workspaceApi.deleteWorkspace).not.toHaveBeenCalled(); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: + 'Cannot delete the last workspace. At least one workspace must exist.', + color: 'red', + }); + }); + + it('does nothing when no current workspace', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.deleteCurrentWorkspace(); + }); + + expect(workspaceApi.deleteWorkspace).not.toHaveBeenCalled(); + expect(mockWorkspaceData.loadWorkspaces).not.toHaveBeenCalled(); + }); + + it('handles delete workspace API errors', async () => { + const mockDeleteWorkspace = vi.mocked(workspaceApi.deleteWorkspace); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + mockDeleteWorkspace.mockRejectedValue(new Error('Delete failed')); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.deleteCurrentWorkspace(); + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to delete workspace:', + expect.any(Error) + ); + expect(notifications.show).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to delete workspace', + color: 'red', + }); + + consoleSpy.mockRestore(); + }); + }); + + describe('updateSettings', () => { + it('updates workspace settings successfully', async () => { + const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace); + const updatedWorkspace: Workspace = { + ...mockWorkspaceData.currentWorkspace!, + autoSave: true, + showHiddenFiles: true, + }; + mockUpdateWorkspace.mockResolvedValue(updatedWorkspace); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + + const { result } = renderHook(() => useWorkspaceOperations()); + + const newSettings = { + autoSave: true, + showHiddenFiles: true, + }; + + await act(async () => { + await result.current.updateSettings(newSettings); + }); + + expect(mockUpdateWorkspace).toHaveBeenCalledWith('test-workspace', { + ...mockWorkspaceData.currentWorkspace, + ...newSettings, + }); + expect(mockWorkspaceData.setCurrentWorkspace).toHaveBeenCalledWith( + updatedWorkspace + ); + expect(mockWorkspaceData.loadWorkspaces).toHaveBeenCalled(); + }); + + it('updates theme and calls updateColorScheme', async () => { + const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace); + const updatedWorkspace: Workspace = { + ...mockWorkspaceData.currentWorkspace!, + theme: Theme.Dark, + }; + mockUpdateWorkspace.mockResolvedValue(updatedWorkspace); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + + const { result } = renderHook(() => useWorkspaceOperations()); + + const newSettings = { + theme: Theme.Dark, + }; + + await act(async () => { + await result.current.updateSettings(newSettings); + }); + + expect(mockUpdateWorkspace).toHaveBeenCalledWith('test-workspace', { + ...mockWorkspaceData.currentWorkspace, + theme: Theme.Dark, + }); + expect(mockTheme.updateColorScheme).toHaveBeenCalledWith(Theme.Dark); + expect(mockWorkspaceData.setCurrentWorkspace).toHaveBeenCalledWith( + updatedWorkspace + ); + }); + + it('updates multiple settings including theme', async () => { + const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace); + const updatedWorkspace: Workspace = { + ...mockWorkspaceData.currentWorkspace!, + theme: Theme.Dark, + autoSave: true, + gitEnabled: true, + gitUrl: 'https://github.com/user/repo.git', + }; + mockUpdateWorkspace.mockResolvedValue(updatedWorkspace); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + + const { result } = renderHook(() => useWorkspaceOperations()); + + const newSettings = { + theme: Theme.Dark, + autoSave: true, + gitEnabled: true, + gitUrl: 'https://github.com/user/repo.git', + }; + + await act(async () => { + await result.current.updateSettings(newSettings); + }); + + expect(mockUpdateWorkspace).toHaveBeenCalledWith('test-workspace', { + ...mockWorkspaceData.currentWorkspace, + ...newSettings, + }); + expect(mockTheme.updateColorScheme).toHaveBeenCalledWith(Theme.Dark); + expect(mockWorkspaceData.setCurrentWorkspace).toHaveBeenCalledWith( + updatedWorkspace + ); + expect(mockWorkspaceData.loadWorkspaces).toHaveBeenCalled(); + }); + + it('does nothing when no current workspace', async () => { + mockWorkspaceData.currentWorkspace = null; + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.updateSettings({ autoSave: true }); + }); + + expect(workspaceApi.updateWorkspace).not.toHaveBeenCalled(); + expect(mockWorkspaceData.setCurrentWorkspace).not.toHaveBeenCalled(); + expect(mockWorkspaceData.loadWorkspaces).not.toHaveBeenCalled(); + }); + + it('handles update workspace API errors', async () => { + const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + mockUpdateWorkspace.mockRejectedValue(new Error('Update failed')); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + try { + await result.current.updateSettings({ autoSave: true }); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Update failed'); + } + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to save settings:', + expect.any(Error) + ); + + consoleSpy.mockRestore(); + }); + + it('handles empty settings update', async () => { + const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace); + const updatedWorkspace = mockWorkspaceData.currentWorkspace!; + mockUpdateWorkspace.mockResolvedValue(updatedWorkspace); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.updateSettings({}); + }); + + expect(mockUpdateWorkspace).toHaveBeenCalledWith( + 'test-workspace', + mockWorkspaceData.currentWorkspace + ); + expect(mockWorkspaceData.setCurrentWorkspace).toHaveBeenCalledWith( + updatedWorkspace + ); + }); + }); + + describe('hook interface', () => { + it('returns correct function interface', () => { + const { result } = renderHook(() => useWorkspaceOperations()); + + expect(typeof result.current.switchWorkspace).toBe('function'); + expect(typeof result.current.deleteCurrentWorkspace).toBe('function'); + expect(typeof result.current.updateSettings).toBe('function'); + }); + + it('functions are stable across re-renders', () => { + const { result, rerender } = renderHook(() => useWorkspaceOperations()); + + const initialFunctions = { + switchWorkspace: result.current.switchWorkspace, + deleteCurrentWorkspace: result.current.deleteCurrentWorkspace, + updateSettings: result.current.updateSettings, + }; + + rerender(); + + expect(result.current.switchWorkspace).toBe( + initialFunctions.switchWorkspace + ); + expect(result.current.deleteCurrentWorkspace).toBe( + initialFunctions.deleteCurrentWorkspace + ); + expect(result.current.updateSettings).toBe( + initialFunctions.updateSettings + ); + }); + }); + + describe('workspace data integration', () => { + it('uses current workspace name for API calls', async () => { + const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace); + const mockDeleteWorkspace = vi.mocked(workspaceApi.deleteWorkspace); + + // Update workspace name + mockWorkspaceData.currentWorkspace = { + ...mockWorkspaceData.currentWorkspace!, + name: 'different-workspace', + }; + + mockUpdateWorkspace.mockResolvedValue(mockWorkspaceData.currentWorkspace); + mockDeleteWorkspace.mockResolvedValue('next-workspace'); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + mockWorkspaceData.loadWorkspaceData.mockResolvedValue(undefined); + + const { result } = renderHook(() => useWorkspaceOperations()); + + // Test update settings + await act(async () => { + await result.current.updateSettings({ autoSave: true }); + }); + + expect(mockUpdateWorkspace).toHaveBeenCalledWith( + 'different-workspace', + expect.any(Object) + ); + + // Test delete workspace + await act(async () => { + await result.current.deleteCurrentWorkspace(); + }); + + expect(mockDeleteWorkspace).toHaveBeenCalledWith('different-workspace'); + }); + + it('handles workspace changes during operations', () => { + const { result, rerender } = renderHook(() => useWorkspaceOperations()); + + // Change workspace + mockWorkspaceData.currentWorkspace = { + ...mockWorkspaceData.currentWorkspace!, + name: 'new-workspace', + }; + + rerender(); + + // Functions should still work with new workspace + expect(typeof result.current.switchWorkspace).toBe('function'); + expect(typeof result.current.deleteCurrentWorkspace).toBe('function'); + expect(typeof result.current.updateSettings).toBe('function'); + }); + }); + + describe('concurrent operations', () => { + + it('handles update settings after switch workspace', async () => { + const mockUpdateLastWorkspaceName = vi.mocked( + workspaceApi.updateLastWorkspaceName + ); + const mockUpdateWorkspace = vi.mocked(workspaceApi.updateWorkspace); + + mockUpdateLastWorkspaceName.mockResolvedValue(undefined); + mockWorkspaceData.loadWorkspaceData.mockResolvedValue(undefined); + mockWorkspaceData.loadWorkspaces.mockResolvedValue(mockWorkspaces); + mockUpdateWorkspace.mockResolvedValue( + mockWorkspaceData.currentWorkspace! + ); + + const { result } = renderHook(() => useWorkspaceOperations()); + + await act(async () => { + await result.current.switchWorkspace('new-workspace'); + }); + + await act(async () => { + await result.current.updateSettings({ autoSave: true }); + }); + + expect(mockUpdateLastWorkspaceName).toHaveBeenCalledWith('new-workspace'); + expect(mockUpdateWorkspace).toHaveBeenCalledWith( + 'test-workspace', + expect.objectContaining({ autoSave: true }) + ); + }); + }); +}); diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts new file mode 100644 index 0000000..b6ec03f --- /dev/null +++ b/app/src/test/setup.ts @@ -0,0 +1,30 @@ +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +// Mock window.API_BASE_URL +Object.defineProperty(window, 'API_BASE_URL', { + value: 'http://localhost:8080/api/v1', + writable: true, +}); + +// Mock matchMedia - required for Mantine components +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock ResizeObserver - sometimes needed for Mantine components +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); diff --git a/app/src/test/utils.tsx b/app/src/test/utils.tsx new file mode 100644 index 0000000..a3e58ef --- /dev/null +++ b/app/src/test/utils.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { render, type RenderOptions } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; + +// Create a custom render function that includes Mantine provider +const AllTheProviders: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + return ( + {children} + ); +}; + +const customRender = ( + ui: React.ReactElement, + options?: Omit +) => render(ui, { wrapper: AllTheProviders, ...options }); + +// Re-export everything +export * from '@testing-library/react'; +export { customRender as render }; diff --git a/app/src/types/api.test.ts b/app/src/types/api.test.ts new file mode 100644 index 0000000..5c78066 --- /dev/null +++ b/app/src/types/api.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect } from 'vitest'; +import { + isLoginResponse, + isLookupResponse, + isSaveFileResponse, + type LoginResponse, + type LookupResponse, + type SaveFileResponse, +} from './api'; +import { UserRole, type User } from './models'; + +// Mock user data for testing +const mockUser: User = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, +}; + +describe('API Type Guards', () => { + describe('isLoginResponse', () => { + it('returns true for valid login response with all fields', () => { + const validLoginResponse: LoginResponse = { + user: mockUser, + sessionId: 'session123', + expiresAt: '2024-01-01T12:00:00Z', + }; + + expect(isLoginResponse(validLoginResponse)).toBe(true); + }); + + it('returns true for valid login response with only required fields', () => { + const validLoginResponse: LoginResponse = { + user: mockUser, + }; + + expect(isLoginResponse(validLoginResponse)).toBe(true); + }); + + it('returns true for valid login response with sessionId only', () => { + const validLoginResponse: LoginResponse = { + user: mockUser, + sessionId: 'session123', + }; + + expect(isLoginResponse(validLoginResponse)).toBe(true); + }); + + it('returns true for valid login response with expiresAt only', () => { + const validLoginResponse: LoginResponse = { + user: mockUser, + expiresAt: '2024-01-01T12:00:00Z', + }; + + expect(isLoginResponse(validLoginResponse)).toBe(true); + }); + + it('returns false for null', () => { + expect(isLoginResponse(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isLoginResponse(undefined)).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isLoginResponse('string')).toBe(false); + expect(isLoginResponse(123)).toBe(false); + expect(isLoginResponse(true)).toBe(false); + expect(isLoginResponse([])).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isLoginResponse({})).toBe(false); + }); + + it('returns false when user field is missing', () => { + const invalidResponse = { + sessionId: 'session123', + expiresAt: '2024-01-01T12:00:00Z', + }; + + expect(isLoginResponse(invalidResponse)).toBe(false); + }); + + it('returns false when user field is invalid', () => { + const invalidResponse = { + user: { + id: 'not-a-number', // Invalid user + email: 'test@example.com', + }, + sessionId: 'session123', + }; + + expect(isLoginResponse(invalidResponse)).toBe(false); + }); + + it('returns false when sessionId is not a string', () => { + const invalidResponse = { + user: mockUser, + sessionId: 123, // Should be string + }; + + expect(isLoginResponse(invalidResponse)).toBe(false); + }); + + it('returns false when expiresAt is not a string', () => { + const invalidResponse = { + user: mockUser, + expiresAt: new Date(), // Should be string + }; + + expect(isLoginResponse(invalidResponse)).toBe(false); + }); + + it('handles objects with extra properties', () => { + const responseWithExtra = { + user: mockUser, + sessionId: 'session123', + expiresAt: '2024-01-01T12:00:00Z', + extraField: 'should be ignored', + }; + + expect(isLoginResponse(responseWithExtra)).toBe(true); + }); + + it('returns false for user with missing required fields', () => { + const invalidUser = { + id: 1, + email: 'test@example.com', + // Missing role, createdAt, lastWorkspaceId + }; + + const invalidResponse = { + user: invalidUser, + }; + + expect(isLoginResponse(invalidResponse)).toBe(false); + }); + + it('handles objects with prototype pollution attempts', () => { + const maliciousObj = { + user: mockUser, + __proto__: { malicious: true }, + constructor: { prototype: { polluted: true } }, + }; + + expect(isLoginResponse(maliciousObj)).toBe(true); + }); + }); + + describe('isLookupResponse', () => { + it('returns true for valid lookup response with paths', () => { + const validLookupResponse: LookupResponse = { + paths: ['path1.md', 'path2.md', 'folder/path3.md'], + }; + + expect(isLookupResponse(validLookupResponse)).toBe(true); + }); + + it('returns true for valid lookup response with empty paths array', () => { + const validLookupResponse: LookupResponse = { + paths: [], + }; + + expect(isLookupResponse(validLookupResponse)).toBe(true); + }); + + it('returns true for single path', () => { + const validLookupResponse: LookupResponse = { + paths: ['single-path.md'], + }; + + expect(isLookupResponse(validLookupResponse)).toBe(true); + }); + + it('returns false for null', () => { + expect(isLookupResponse(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isLookupResponse(undefined)).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isLookupResponse('string')).toBe(false); + expect(isLookupResponse(123)).toBe(false); + expect(isLookupResponse(true)).toBe(false); + expect(isLookupResponse([])).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isLookupResponse({})).toBe(false); + }); + + it('returns false when paths field is missing', () => { + const invalidResponse = { + otherField: 'value', + }; + + expect(isLookupResponse(invalidResponse)).toBe(false); + }); + + it('returns false when paths is not an array', () => { + const invalidResponse = { + paths: 'not-an-array', + }; + + expect(isLookupResponse(invalidResponse)).toBe(false); + }); + + it('returns false when paths contains non-string values', () => { + const invalidResponse = { + paths: ['valid-path.md', 123, 'another-path.md'], + }; + + expect(isLookupResponse(invalidResponse)).toBe(false); + }); + + it('returns false when paths contains null values', () => { + const invalidResponse = { + paths: ['path1.md', null, 'path2.md'], + }; + + expect(isLookupResponse(invalidResponse)).toBe(false); + }); + + it('returns false when paths contains undefined values', () => { + const invalidResponse = { + paths: ['path1.md', undefined, 'path2.md'], + }; + + expect(isLookupResponse(invalidResponse)).toBe(false); + }); + + it('handles objects with extra properties', () => { + const responseWithExtra = { + paths: ['path1.md', 'path2.md'], + extraField: 'should be ignored', + }; + + expect(isLookupResponse(responseWithExtra)).toBe(true); + }); + + it('handles objects with prototype pollution attempts', () => { + const maliciousObj = { + paths: ['path1.md', 'path2.md'], + __proto__: { malicious: true }, + constructor: { prototype: { polluted: true } }, + }; + + expect(isLookupResponse(maliciousObj)).toBe(true); + }); + + it('handles complex path strings', () => { + const validLookupResponse: LookupResponse = { + paths: [ + 'simple.md', + 'folder/nested.md', + 'deep/nested/path/file.md', + 'file with spaces.md', + 'special-chars_123.md', + 'unicode-文件.md', + ], + }; + + expect(isLookupResponse(validLookupResponse)).toBe(true); + }); + }); + + describe('isSaveFileResponse', () => { + it('returns true for valid save file response', () => { + const validSaveFileResponse: SaveFileResponse = { + filePath: 'documents/test.md', + size: 1024, + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(validSaveFileResponse)).toBe(true); + }); + + it('returns true for save file response with zero size', () => { + const validSaveFileResponse: SaveFileResponse = { + filePath: 'empty.md', + size: 0, + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(validSaveFileResponse)).toBe(true); + }); + + it('returns true for save file response with large size', () => { + const validSaveFileResponse: SaveFileResponse = { + filePath: 'large-file.md', + size: 999999999, + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(validSaveFileResponse)).toBe(true); + }); + + it('returns false for null', () => { + expect(isSaveFileResponse(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isSaveFileResponse(undefined)).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isSaveFileResponse('string')).toBe(false); + expect(isSaveFileResponse(123)).toBe(false); + expect(isSaveFileResponse(true)).toBe(false); + expect(isSaveFileResponse([])).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isSaveFileResponse({})).toBe(false); + }); + + it('returns false when filePath is missing', () => { + const invalidResponse = { + size: 1024, + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(invalidResponse)).toBe(false); + }); + + it('returns false when filePath is not a string', () => { + const invalidResponse = { + filePath: 123, + size: 1024, + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(invalidResponse)).toBe(false); + }); + + it('returns false when size is missing', () => { + const invalidResponse = { + filePath: 'test.md', + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(invalidResponse)).toBe(false); + }); + + it('returns false when size is not a number', () => { + const invalidResponse = { + filePath: 'test.md', + size: '1024', + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(invalidResponse)).toBe(false); + }); + + it('returns false when updatedAt is missing', () => { + const invalidResponse = { + filePath: 'test.md', + size: 1024, + }; + + expect(isSaveFileResponse(invalidResponse)).toBe(false); + }); + + it('returns false when updatedAt is not a string', () => { + const invalidResponse = { + filePath: 'test.md', + size: 1024, + updatedAt: new Date(), + }; + + expect(isSaveFileResponse(invalidResponse)).toBe(false); + }); + + it('returns false for negative size', () => { + const invalidResponse = { + filePath: 'test.md', + size: -1, + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(invalidResponse)).toBe(true); // Note: Type guard doesn't validate negative numbers + }); + + it('handles objects with prototype pollution attempts', () => { + const maliciousObj = { + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T10:00:00Z', + __proto__: { malicious: true }, + constructor: { prototype: { polluted: true } }, + }; + + expect(isSaveFileResponse(maliciousObj)).toBe(true); + }); + + it('handles objects with extra properties', () => { + const responseWithExtra = { + filePath: 'test.md', + size: 1024, + updatedAt: '2024-01-01T10:00:00Z', + extraField: 'should be ignored', + }; + + expect(isSaveFileResponse(responseWithExtra)).toBe(true); + }); + + it('handles complex file paths', () => { + const validSaveFileResponse: SaveFileResponse = { + filePath: 'deep/nested/path/file with spaces & symbols.md', + size: 2048, + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(validSaveFileResponse)).toBe(true); + }); + + it('handles various ISO date formats', () => { + const dateFormats = [ + '2024-01-01T10:00:00Z', + '2024-01-01T10:00:00.000Z', + '2024-01-01T10:00:00+00:00', + '2024-01-01T10:00:00.123456Z', + ]; + + dateFormats.forEach((dateString) => { + const validResponse: SaveFileResponse = { + filePath: 'test.md', + size: 1024, + updatedAt: dateString, + }; + + expect(isSaveFileResponse(validResponse)).toBe(true); + }); + }); + }); + + describe('edge cases and error conditions', () => { + it('handles circular references gracefully', () => { + const circularObj: { paths: string[]; self?: unknown } = { paths: [] }; + circularObj.self = circularObj; + + // Should not throw an error + expect(isLookupResponse(circularObj)).toBe(true); + }); + + it('handles deeply nested objects', () => { + const deeplyNested = { + user: { + ...mockUser, + nested: { + deep: { + deeper: { + value: 'test', + }, + }, + }, + }, + }; + + expect(isLoginResponse(deeplyNested)).toBe(true); + }); + + it('handles frozen objects', () => { + const frozenResponse = Object.freeze({ + paths: Object.freeze(['path1.md', 'path2.md']), + }); + + expect(isLookupResponse(frozenResponse)).toBe(true); + }); + + it('handles objects created with null prototype', () => { + const nullProtoObj = Object.create(null) as Record; + nullProtoObj['filePath'] = 'test.md'; + nullProtoObj['size'] = 1024; + nullProtoObj['updatedAt'] = '2024-01-01T10:00:00Z'; + + expect(isSaveFileResponse(nullProtoObj)).toBe(true); + }); + }); + + describe('performance with large data', () => { + it('handles large paths arrays efficiently', () => { + const largePaths = Array.from({ length: 10000 }, (_, i) => `path${i}.md`); + const largeResponse = { + paths: largePaths, + }; + + const start = performance.now(); + const result = isLookupResponse(largeResponse); + const end = performance.now(); + + expect(result).toBe(true); + expect(end - start).toBeLessThan(100); // Should complete in under 100ms + }); + + it('handles very long file paths', () => { + const longPath = 'a'.repeat(10000); + const responseWithLongPath: SaveFileResponse = { + filePath: longPath, + size: 1024, + updatedAt: '2024-01-01T10:00:00Z', + }; + + expect(isSaveFileResponse(responseWithLongPath)).toBe(true); + }); + }); +}); diff --git a/app/src/types/models.test.ts b/app/src/types/models.test.ts new file mode 100644 index 0000000..ca9fa3a --- /dev/null +++ b/app/src/types/models.test.ts @@ -0,0 +1,867 @@ +import { describe, it, expect } from 'vitest'; +import { + isUser, + isUserRole, + isWorkspace, + isFileNode, + isSystemStats, + UserRole, + Theme, + type User, + type Workspace, + type FileNode, + type SystemStats, +} from './models'; + +describe('Models Type Guards', () => { + describe('isUserRole', () => { + it('returns true for valid admin role', () => { + expect(isUserRole(UserRole.Admin)).toBe(true); + expect(isUserRole('admin')).toBe(true); + }); + + it('returns true for valid editor role', () => { + expect(isUserRole(UserRole.Editor)).toBe(true); + expect(isUserRole('editor')).toBe(true); + }); + + it('returns true for valid viewer role', () => { + expect(isUserRole(UserRole.Viewer)).toBe(true); + expect(isUserRole('viewer')).toBe(true); + }); + + it('returns false for invalid role strings', () => { + expect(isUserRole('invalid')).toBe(false); + expect(isUserRole('Administrator')).toBe(false); + expect(isUserRole('ADMIN')).toBe(false); + expect(isUserRole('')).toBe(false); + }); + + it('returns false for non-string values', () => { + expect(isUserRole(123)).toBe(false); + expect(isUserRole(null)).toBe(false); + expect(isUserRole(undefined)).toBe(false); + expect(isUserRole({})).toBe(false); + expect(isUserRole([])).toBe(false); + expect(isUserRole(true)).toBe(false); + }); + + it('handles objects with prototype pollution attempts', () => { + const maliciousRole = 'editor'; + Object.setPrototypeOf(maliciousRole, { + toString: () => 'malicious', + valueOf: () => 'malicious', + }); + + expect(isUserRole(maliciousRole)).toBe(true); + }); + }); + + describe('isUser', () => { + const validUser: User = { + id: 1, + email: 'test@example.com', + displayName: 'Test User', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + it('returns true for valid user with all fields', () => { + expect(isUser(validUser)).toBe(true); + }); + + it('returns true for valid user without optional displayName', () => { + const userWithoutDisplayName: User = { + id: 1, + email: 'test@example.com', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + expect(isUser(userWithoutDisplayName)).toBe(true); + }); + + it('returns true for valid user with empty displayName', () => { + const userWithEmptyDisplayName = { + ...validUser, + displayName: '', + }; + + expect(isUser(userWithEmptyDisplayName)).toBe(true); + }); + + it('returns false for null', () => { + expect(isUser(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isUser(undefined)).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isUser('string')).toBe(false); + expect(isUser(123)).toBe(false); + expect(isUser(true)).toBe(false); + expect(isUser([])).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isUser({})).toBe(false); + }); + + it('returns false when id is missing', () => { + const { id: _id, ...userWithoutId } = validUser; + expect(isUser(userWithoutId)).toBe(false); + }); + + it('returns false when id is not a number', () => { + const userWithInvalidId = { ...validUser, id: '1' }; + expect(isUser(userWithInvalidId)).toBe(false); + }); + + it('returns false when email is missing', () => { + const { email: _email, ...userWithoutEmail } = validUser; + expect(isUser(userWithoutEmail)).toBe(false); + }); + + it('returns false when email is not a string', () => { + const userWithInvalidEmail = { ...validUser, email: 123 }; + expect(isUser(userWithInvalidEmail)).toBe(false); + }); + + it('returns false when displayName is not a string', () => { + const userWithInvalidDisplayName = { ...validUser, displayName: 123 }; + expect(isUser(userWithInvalidDisplayName)).toBe(false); + }); + + it('returns false when role is missing', () => { + const { role: _role, ...userWithoutRole } = validUser; + expect(isUser(userWithoutRole)).toBe(false); + }); + + it('returns false when role is invalid', () => { + const userWithInvalidRole = { ...validUser, role: 'invalid' }; + expect(isUser(userWithInvalidRole)).toBe(false); + }); + + it('returns false when createdAt is missing', () => { + const { createdAt: _createdAt, ...userWithoutCreatedAt } = validUser; + expect(isUser(userWithoutCreatedAt)).toBe(false); + }); + + it('returns false when createdAt is not a string', () => { + const userWithInvalidCreatedAt = { ...validUser, createdAt: new Date() }; + expect(isUser(userWithInvalidCreatedAt)).toBe(false); + }); + + it('returns false when lastWorkspaceId is missing', () => { + const { + lastWorkspaceId: _lastWorkspaceId, + ...userWithoutLastWorkspaceId + } = validUser; + expect(isUser(userWithoutLastWorkspaceId)).toBe(false); + }); + + it('returns false when lastWorkspaceId is not a number', () => { + const userWithInvalidLastWorkspaceId = { + ...validUser, + lastWorkspaceId: '1', + }; + expect(isUser(userWithInvalidLastWorkspaceId)).toBe(false); + }); + + it('handles objects with extra properties', () => { + const userWithExtra = { + ...validUser, + extraField: 'should be ignored', + }; + + expect(isUser(userWithExtra)).toBe(true); + }); + + it('handles objects with prototype pollution attempts', () => { + const maliciousUser = { + id: 1, + email: 'test@example.com', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + __proto__: { malicious: true }, + constructor: { prototype: { polluted: true } }, + }; + + expect(isUser(maliciousUser)).toBe(true); + }); + + it('handles different user roles', () => { + expect(isUser({ ...validUser, role: UserRole.Admin })).toBe(true); + expect(isUser({ ...validUser, role: UserRole.Editor })).toBe(true); + expect(isUser({ ...validUser, role: UserRole.Viewer })).toBe(true); + }); + + it('handles various email formats', () => { + const emailFormats = [ + 'simple@example.com', + 'user.name@example.com', + 'user+tag@example.com', + 'user123@example-domain.com', + 'very.long.email.address@very.long.domain.name.com', + ]; + + emailFormats.forEach((email) => { + expect(isUser({ ...validUser, email })).toBe(true); + }); + }); + }); + + describe('isWorkspace', () => { + const validWorkspace: Workspace = { + id: 1, + userId: 1, + name: 'test-workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }; + + it('returns true for valid workspace with all fields', () => { + expect(isWorkspace(validWorkspace)).toBe(true); + }); + + it('returns true for workspace without optional id and userId', () => { + const { + id: _id, + userId: _userId, + ...workspaceWithoutIds + } = validWorkspace; + expect(isWorkspace(workspaceWithoutIds)).toBe(true); + }); + + it('returns true for workspace with numeric createdAt', () => { + const workspaceWithNumericCreatedAt = { + ...validWorkspace, + createdAt: Date.now(), + }; + + expect(isWorkspace(workspaceWithNumericCreatedAt)).toBe(true); + }); + + it('returns true for workspace with dark theme', () => { + const darkWorkspace = { + ...validWorkspace, + theme: Theme.Dark, + }; + + expect(isWorkspace(darkWorkspace)).toBe(true); + }); + + it('returns true for workspace with git enabled', () => { + const gitWorkspace = { + ...validWorkspace, + gitEnabled: true, + gitUrl: 'https://github.com/user/repo.git', + gitUser: 'username', + gitToken: 'token123', + gitAutoCommit: true, + gitCommitMsgTemplate: 'auto: ${action} ${filename}', + gitCommitName: 'Git User', + gitCommitEmail: 'git@example.com', + }; + + expect(isWorkspace(gitWorkspace)).toBe(true); + }); + + it('returns false for null', () => { + expect(isWorkspace(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isWorkspace(undefined)).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isWorkspace('string')).toBe(false); + expect(isWorkspace(123)).toBe(false); + expect(isWorkspace(true)).toBe(false); + expect(isWorkspace([])).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isWorkspace({})).toBe(false); + }); + + it('returns false when name is missing', () => { + const { name: _name, ...workspaceWithoutName } = validWorkspace; + expect(isWorkspace(workspaceWithoutName)).toBe(false); + }); + + it('returns false when name is not a string', () => { + const workspaceWithInvalidName = { ...validWorkspace, name: 123 }; + expect(isWorkspace(workspaceWithInvalidName)).toBe(false); + }); + + it('returns false when createdAt is missing', () => { + const { createdAt: _createdAt, ...workspaceWithoutCreatedAt } = + validWorkspace; + expect(isWorkspace(workspaceWithoutCreatedAt)).toBe(false); + }); + + it('returns false when createdAt is neither string nor number', () => { + const workspaceWithInvalidCreatedAt = { + ...validWorkspace, + createdAt: new Date(), + }; + expect(isWorkspace(workspaceWithInvalidCreatedAt)).toBe(false); + }); + + it('returns false when theme is missing', () => { + const { theme: _theme, ...workspaceWithoutTheme } = validWorkspace; + expect(isWorkspace(workspaceWithoutTheme)).toBe(false); + }); + + it('returns false when theme is not a string', () => { + const workspaceWithInvalidTheme = { ...validWorkspace, theme: 123 }; + expect(isWorkspace(workspaceWithInvalidTheme)).toBe(false); + }); + + it('returns false when boolean fields are not boolean', () => { + const booleanFields = [ + 'autoSave', + 'showHiddenFiles', + 'gitEnabled', + 'gitAutoCommit', + ]; + + booleanFields.forEach((field) => { + const workspaceWithInvalidBoolean = { + ...validWorkspace, + [field]: 'true', + }; + expect(isWorkspace(workspaceWithInvalidBoolean)).toBe(false); + }); + }); + + it('returns false when string fields are not strings', () => { + const stringFields = [ + 'gitUrl', + 'gitUser', + 'gitToken', + 'gitCommitMsgTemplate', + 'gitCommitName', + 'gitCommitEmail', + ]; + + stringFields.forEach((field) => { + const workspaceWithInvalidString = { ...validWorkspace, [field]: 123 }; + expect(isWorkspace(workspaceWithInvalidString)).toBe(false); + }); + }); + + it('handles objects with extra properties', () => { + const workspaceWithExtra = { + ...validWorkspace, + extraField: 'should be ignored', + }; + + expect(isWorkspace(workspaceWithExtra)).toBe(true); + }); + + it('handles objects with prototype pollution attempts', () => { + const maliciousWorkspace = { + ...validWorkspace, + __proto__: { malicious: true }, + constructor: { prototype: { polluted: true } }, + }; + + expect(isWorkspace(maliciousWorkspace)).toBe(true); + }); + + it('handles various workspace names', () => { + const workspaceNames = [ + 'simple', + 'workspace-with-dashes', + 'workspace_with_underscores', + 'workspace with spaces', + 'workspace123', + 'very-long-workspace-name-with-many-characters', + 'unicode-工作区', + ]; + + workspaceNames.forEach((name) => { + expect(isWorkspace({ ...validWorkspace, name })).toBe(true); + }); + }); + }); + + describe('isFileNode', () => { + const validFileNode: FileNode = { + id: '1', + name: 'test.md', + path: 'documents/test.md', + }; + + const validFolderNode: FileNode = { + id: '2', + name: 'documents', + path: 'documents', + children: [ + { + id: '3', + name: 'nested.md', + path: 'documents/nested.md', + }, + ], + }; + + it('returns true for valid file node without children', () => { + expect(isFileNode(validFileNode)).toBe(true); + }); + + it('returns true for valid folder node with children', () => { + expect(isFileNode(validFolderNode)).toBe(true); + }); + + it('returns true for node with empty children array', () => { + const nodeWithEmptyChildren = { + ...validFileNode, + children: [], + }; + + expect(isFileNode(nodeWithEmptyChildren)).toBe(true); + }); + + it('returns true for node with null children', () => { + const nodeWithNullChildren = { + ...validFileNode, + children: null, + }; + + expect(isFileNode(nodeWithNullChildren)).toBe(true); + }); + + it('returns true for node with undefined children', () => { + const nodeWithUndefinedChildren = { + ...validFileNode, + children: undefined, + }; + + expect(isFileNode(nodeWithUndefinedChildren)).toBe(true); + }); + + it('returns false for null', () => { + expect(isFileNode(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isFileNode(undefined)).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isFileNode('string')).toBe(false); + expect(isFileNode(123)).toBe(false); + expect(isFileNode(true)).toBe(false); + expect(isFileNode([])).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isFileNode({})).toBe(false); + }); + + it('returns false when id is missing', () => { + const { id: _id, ...nodeWithoutId } = validFileNode; + expect(isFileNode(nodeWithoutId)).toBe(false); + }); + + it('returns false when id is not a string', () => { + const nodeWithInvalidId = { ...validFileNode, id: 123 }; + expect(isFileNode(nodeWithInvalidId)).toBe(false); + }); + + it('returns false when name is missing', () => { + const { name: _name, ...nodeWithoutName } = validFileNode; + expect(isFileNode(nodeWithoutName)).toBe(false); + }); + + it('returns false when name is not a string', () => { + const nodeWithInvalidName = { ...validFileNode, name: 123 }; + expect(isFileNode(nodeWithInvalidName)).toBe(false); + }); + + it('returns false when path is missing', () => { + const { path: _path, ...nodeWithoutPath } = validFileNode; + expect(isFileNode(nodeWithoutPath)).toBe(false); + }); + + it('returns false when path is not a string', () => { + const nodeWithInvalidPath = { ...validFileNode, path: 123 }; + expect(isFileNode(nodeWithInvalidPath)).toBe(false); + }); + + it('returns false when children is not an array', () => { + const nodeWithInvalidChildren = { + ...validFileNode, + children: 'not-an-array', + }; + + expect(isFileNode(nodeWithInvalidChildren)).toBe(false); + }); + + it('handles nested file structures', () => { + const deeplyNestedNode: FileNode = { + id: '1', + name: 'root', + path: 'root', + children: [ + { + id: '2', + name: 'level1', + path: 'root/level1', + children: [ + { + id: '3', + name: 'level2', + path: 'root/level1/level2', + children: [ + { + id: '4', + name: 'deep-file.md', + path: 'root/level1/level2/deep-file.md', + }, + ], + }, + ], + }, + ], + }; + + expect(isFileNode(deeplyNestedNode)).toBe(true); + }); + + it('handles various file and folder names', () => { + const names = [ + 'simple.md', + 'file-with-dashes.md', + 'file_with_underscores.md', + 'file with spaces.md', + 'file.with.dots.md', + 'UPPERCASE.MD', + 'MixedCase.Md', + 'unicode-文件.md', + 'no-extension', + '.hidden-file', + 'folder', + ]; + + names.forEach((name) => { + const node = { + id: '1', + name, + path: name, + }; + expect(isFileNode(node)).toBe(true); + }); + }); + + it('handles objects with extra properties', () => { + const nodeWithExtra = { + ...validFileNode, + extraField: 'should be ignored', + }; + + expect(isFileNode(nodeWithExtra)).toBe(true); + }); + + it('handles objects with prototype pollution attempts', () => { + const maliciousNode = { + ...validFileNode, + __proto__: { malicious: true }, + constructor: { prototype: { polluted: true } }, + }; + + expect(isFileNode(maliciousNode)).toBe(true); + }); + }); + + describe('isSystemStats', () => { + const validSystemStats: SystemStats = { + totalUsers: 10, + totalWorkspaces: 5, + activeUsers: 8, + totalFiles: 100, + totalSize: 1024000, + }; + + it('returns true for valid system stats', () => { + expect(isSystemStats(validSystemStats)).toBe(true); + }); + + it('returns true for stats with zero values', () => { + const statsWithZeros = { + totalUsers: 0, + totalWorkspaces: 0, + activeUsers: 0, + totalFiles: 0, + totalSize: 0, + }; + + expect(isSystemStats(statsWithZeros)).toBe(true); + }); + + it('returns true for stats with large numbers', () => { + const statsWithLargeNumbers = { + totalUsers: 999999, + totalWorkspaces: 888888, + activeUsers: 777777, + totalFiles: 666666, + totalSize: 555555555555, + }; + + expect(isSystemStats(statsWithLargeNumbers)).toBe(true); + }); + + it('returns false for null', () => { + expect(isSystemStats(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isSystemStats(undefined)).toBe(false); + }); + + it('returns false for non-object values', () => { + expect(isSystemStats('string')).toBe(false); + expect(isSystemStats(123)).toBe(false); + expect(isSystemStats(true)).toBe(false); + expect(isSystemStats([])).toBe(false); + }); + + it('returns false for empty object', () => { + expect(isSystemStats({})).toBe(false); + }); + + it('returns false when totalUsers is missing', () => { + const { totalUsers: _totalUsers, ...statsWithoutTotalUsers } = + validSystemStats; + expect(isSystemStats(statsWithoutTotalUsers)).toBe(false); + }); + + it('returns false when totalUsers is not a number', () => { + const statsWithInvalidTotalUsers = { + ...validSystemStats, + totalUsers: '10', + }; + expect(isSystemStats(statsWithInvalidTotalUsers)).toBe(false); + }); + + it('returns false when totalWorkspaces is missing', () => { + const { + totalWorkspaces: _totalWorkspaces, + ...statsWithoutTotalWorkspaces + } = validSystemStats; + expect(isSystemStats(statsWithoutTotalWorkspaces)).toBe(false); + }); + + it('returns false when totalWorkspaces is not a number', () => { + const statsWithInvalidTotalWorkspaces = { + ...validSystemStats, + totalWorkspaces: '5', + }; + expect(isSystemStats(statsWithInvalidTotalWorkspaces)).toBe(false); + }); + + it('returns false when activeUsers is missing', () => { + const { activeUsers: _activeUsers, ...statsWithoutActiveUsers } = + validSystemStats; + expect(isSystemStats(statsWithoutActiveUsers)).toBe(false); + }); + + it('returns false when activeUsers is not a number', () => { + const statsWithInvalidActiveUsers = { + ...validSystemStats, + activeUsers: '8', + }; + expect(isSystemStats(statsWithInvalidActiveUsers)).toBe(false); + }); + + it('returns false when totalFiles is missing', () => { + const { totalFiles: _totalFiles, ...statsWithoutTotalFiles } = + validSystemStats; + expect(isSystemStats(statsWithoutTotalFiles)).toBe(false); + }); + + it('returns false when totalFiles is not a number', () => { + const statsWithInvalidTotalFiles = { + ...validSystemStats, + totalFiles: '100', + }; + expect(isSystemStats(statsWithInvalidTotalFiles)).toBe(false); + }); + + it('returns false when totalSize is missing', () => { + const { totalSize: _totalSize, ...statsWithoutTotalSize } = + validSystemStats; + expect(isSystemStats(statsWithoutTotalSize)).toBe(false); + }); + + it('returns false when totalSize is not a number', () => { + const statsWithInvalidTotalSize = { + ...validSystemStats, + totalSize: '1024000', + }; + expect(isSystemStats(statsWithInvalidTotalSize)).toBe(false); + }); + + it('handles objects with extra properties', () => { + const statsWithExtra = { + ...validSystemStats, + extraField: 'should be ignored', + }; + + expect(isSystemStats(statsWithExtra)).toBe(true); + }); + + it('handles objects with prototype pollution attempts', () => { + const maliciousStats = { + ...validSystemStats, + __proto__: { malicious: true }, + constructor: { prototype: { polluted: true } }, + }; + + expect(isSystemStats(maliciousStats)).toBe(true); + }); + + it('handles floating point numbers', () => { + const statsWithFloats = { + totalUsers: 10.5, + totalWorkspaces: 5.7, + activeUsers: 8.2, + totalFiles: 100.9, + totalSize: 1024000.123, + }; + + expect(isSystemStats(statsWithFloats)).toBe(true); + }); + + it('handles negative numbers', () => { + const statsWithNegatives = { + totalUsers: -10, + totalWorkspaces: -5, + activeUsers: -8, + totalFiles: -100, + totalSize: -1024000, + }; + + expect(isSystemStats(statsWithNegatives)).toBe(true); // Type guard doesn't validate ranges + }); + }); + + describe('edge cases and error conditions', () => { + it('handles circular references gracefully', () => { + const circularUser: Record = { + id: 1, + email: 'test@example.com', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + circularUser['self'] = circularUser; + + expect(isUser(circularUser)).toBe(true); + }); + + it('handles deeply nested file structures', () => { + let deepNode: FileNode = { + id: '1', + name: 'root', + path: 'root', + }; + + // Create a deeply nested structure + for (let i = 2; i <= 100; i++) { + deepNode = { + id: i.toString(), + name: `level${i}`, + path: `root/level${i}`, + children: [deepNode], + }; + } + + expect(isFileNode(deepNode)).toBe(true); + }); + + it('handles frozen objects', () => { + const frozenUser = Object.freeze({ + id: 1, + email: 'test@example.com', + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }); + + expect(isUser(frozenUser)).toBe(true); + }); + + it('handles objects created with null prototype', () => { + const nullProtoStats = Object.create(null) as Record; + nullProtoStats['totalUsers'] = 10; + nullProtoStats['totalWorkspaces'] = 5; + nullProtoStats['activeUsers'] = 8; + nullProtoStats['totalFiles'] = 100; + nullProtoStats['totalSize'] = 1024000; + + expect(isSystemStats(nullProtoStats)).toBe(true); + }); + }); + + describe('performance with large data', () => { + it('handles large file trees efficiently', () => { + const largeChildren = Array.from({ length: 1000 }, (_, i) => ({ + id: i.toString(), + name: `file${i}.md`, + path: `folder/file${i}.md`, + })); + + const largeFileTree: FileNode = { + id: 'root', + name: 'folder', + path: 'folder', + children: largeChildren, + }; + + const start = performance.now(); + const result = isFileNode(largeFileTree); + const end = performance.now(); + + expect(result).toBe(true); + expect(end - start).toBeLessThan(100); // Should complete in under 100ms + }); + + it('handles very long strings efficiently', () => { + const longString = 'a'.repeat(100000); + const userWithLongEmail = { + id: 1, + email: longString, + role: UserRole.Editor, + createdAt: '2024-01-01T00:00:00Z', + lastWorkspaceId: 1, + }; + + const start = performance.now(); + const result = isUser(userWithLongEmail); + const end = performance.now(); + + expect(result).toBe(true); + expect(end - start).toBeLessThan(10); // Should be very fast + }); + }); +}); diff --git a/app/src/types/models.ts b/app/src/types/models.ts index 3c17535..89a8967 100644 --- a/app/src/types/models.ts +++ b/app/src/types/models.ts @@ -1,3 +1,5 @@ +import type { Parent } from 'unist'; + /** * User model from the API */ @@ -206,12 +208,46 @@ export interface WorkspaceStats { fileCountStats?: FileCountStats; } +// isWorkspaceStats checks if the given object is a valid WorkspaceStats object +export function isWorkspaceStats(obj: unknown): obj is WorkspaceStats { + return ( + typeof obj === 'object' && + obj !== null && + 'userID' in obj && + typeof (obj as WorkspaceStats).userID === 'number' && + 'userEmail' in obj && + typeof (obj as WorkspaceStats).userEmail === 'string' && + 'workspaceID' in obj && + typeof (obj as WorkspaceStats).workspaceID === 'number' && + 'workspaceName' in obj && + typeof (obj as WorkspaceStats).workspaceName === 'string' && + 'workspaceCreatedAt' in obj && + typeof (obj as WorkspaceStats).workspaceCreatedAt === 'string' && + (!('fileCountStats' in obj) || + (obj as WorkspaceStats).fileCountStats === undefined || + (obj as WorkspaceStats).fileCountStats === null || + isFileCountStats((obj as WorkspaceStats).fileCountStats)) + ); +} + // Define FileCountStats based on the Go struct definition of storage.FileCountStats export interface FileCountStats { totalFiles: number; totalSize: number; } +// isFileCountStats checks if the given object is a valid FileCountStats object +export function isFileCountStats(obj: unknown): obj is FileCountStats { + return ( + typeof obj === 'object' && + obj !== null && + 'totalFiles' in obj && + typeof (obj as FileCountStats).totalFiles === 'number' && + 'totalSize' in obj && + typeof (obj as FileCountStats).totalSize === 'number' + ); +} + export interface UserStats { totalUsers: number; totalWorkspaces: number; @@ -285,3 +321,66 @@ export interface SettingsAction { type: SettingsActionType; payload?: T; } + +// WikiLinks + +/** + * Represents a wiki link match from the regex + */ +export interface WikiLinkMatch { + fullMatch: string; + isImage: boolean; // Changed from string to boolean + fileName: string; + displayText: string; + heading?: string | undefined; + index: number; +} + +/** + * Node replacement information for processing + */ +export interface ReplacementInfo { + matches: WikiLinkMatch[]; + parent: Parent; + index: number; +} + +/** + * Properties for link nodes + */ +export interface LinkNodeProps { + style?: { + color?: string; + textDecoration?: string; + }; +} + +/** + * Link node with data properties + */ +export interface LinkNode extends Node { + type: 'link'; + url: string; + children: Node[]; + data?: { + hProperties?: LinkNodeProps; + }; +} + +/** + * Image node + */ +export interface ImageNode extends Node { + type: 'image'; + url: string; + alt?: string; + title?: string; +} + +/** + * Text node + */ +export interface TextNode extends Node { + type: 'text'; + value: string; +} diff --git a/app/src/utils/fileHelpers.test.ts b/app/src/utils/fileHelpers.test.ts new file mode 100644 index 0000000..f1fc0ab --- /dev/null +++ b/app/src/utils/fileHelpers.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { isImageFile, getFileUrl } from './fileHelpers'; + +describe('fileHelpers', () => { + beforeEach(() => { + // Ensure API_BASE_URL is set for tests + window.API_BASE_URL = 'http://localhost:8080/api/v1'; + }); + + describe('isImageFile', () => { + it('returns true for supported image file extensions', () => { + expect(isImageFile('image.jpg')).toBe(true); + expect(isImageFile('image.jpeg')).toBe(true); + expect(isImageFile('image.png')).toBe(true); + expect(isImageFile('image.gif')).toBe(true); + expect(isImageFile('image.webp')).toBe(true); + expect(isImageFile('image.svg')).toBe(true); + }); + + it('returns true for uppercase image file extensions', () => { + expect(isImageFile('image.JPG')).toBe(true); + expect(isImageFile('image.JPEG')).toBe(true); + expect(isImageFile('image.PNG')).toBe(true); + expect(isImageFile('image.GIF')).toBe(true); + expect(isImageFile('image.WEBP')).toBe(true); + expect(isImageFile('image.SVG')).toBe(true); + }); + + it('returns true for mixed case image file extensions', () => { + expect(isImageFile('image.JpG')).toBe(true); + expect(isImageFile('image.JpEg')).toBe(true); + expect(isImageFile('image.PnG')).toBe(true); + expect(isImageFile('screenshot.WeBp')).toBe(true); + }); + + it('returns false for non-image file extensions', () => { + expect(isImageFile('document.md')).toBe(false); + expect(isImageFile('document.txt')).toBe(false); + expect(isImageFile('document.pdf')).toBe(false); + expect(isImageFile('document.docx')).toBe(false); + expect(isImageFile('script.js')).toBe(false); + expect(isImageFile('style.css')).toBe(false); + expect(isImageFile('data.json')).toBe(false); + expect(isImageFile('archive.zip')).toBe(false); + }); + + it('returns false for files without extensions', () => { + expect(isImageFile('README')).toBe(false); + expect(isImageFile('Dockerfile')).toBe(false); + expect(isImageFile('LICENSE')).toBe(false); + expect(isImageFile('Makefile')).toBe(false); + }); + + it('handles complex file paths correctly', () => { + expect(isImageFile('path/to/image.jpg')).toBe(true); + expect(isImageFile('./relative/path/image.png')).toBe(true); + expect(isImageFile('/absolute/path/image.gif')).toBe(true); + expect(isImageFile('../../parent/image.svg')).toBe(true); + expect(isImageFile('path/to/document.md')).toBe(false); + expect(isImageFile('./config/settings.json')).toBe(false); + }); + + it('handles files with multiple dots in filename', () => { + expect(isImageFile('my.image.file.jpg')).toBe(true); + expect(isImageFile('config.backup.json')).toBe(false); + expect(isImageFile('version.1.2.png')).toBe(true); + expect(isImageFile('app.config.local.js')).toBe(false); + expect(isImageFile('test.component.spec.ts')).toBe(false); + }); + + it('handles edge cases', () => { + expect(isImageFile('')).toBe(false); + expect(isImageFile('.')).toBe(false); + expect(isImageFile('.jpg')).toBe(true); + expect(isImageFile('.hidden.png')).toBe(true); + expect(isImageFile('file.')).toBe(false); + }); + }); + + describe('getFileUrl', () => { + it('constructs correct file URL with simple parameters', () => { + const workspaceName = 'my-workspace'; + const filePath = 'folder/file.md'; + + const expectedUrl = + 'http://localhost:8080/api/v1/workspaces/my-workspace/files/folder%2Ffile.md'; + const actualUrl = getFileUrl(workspaceName, filePath); + + expect(actualUrl).toBe(expectedUrl); + }); + + it('properly encodes workspace name with special characters', () => { + const workspaceName = 'my workspace with spaces'; + const filePath = 'file.md'; + + const expectedUrl = + 'http://localhost:8080/api/v1/workspaces/my%20workspace%20with%20spaces/files/file.md'; + const actualUrl = getFileUrl(workspaceName, filePath); + + expect(actualUrl).toBe(expectedUrl); + }); + + it('properly encodes file path with special characters', () => { + const workspaceName = 'workspace'; + const filePath = 'folder with spaces/file with spaces.md'; + + const expectedUrl = + 'http://localhost:8080/api/v1/workspaces/workspace/files/folder%20with%20spaces%2Ffile%20with%20spaces.md'; + const actualUrl = getFileUrl(workspaceName, filePath); + + expect(actualUrl).toBe(expectedUrl); + }); + + it('handles special URL characters that need encoding', () => { + const workspaceName = 'test&workspace'; + const filePath = 'file?name=test.md'; + + const expectedUrl = + 'http://localhost:8080/api/v1/workspaces/test%26workspace/files/file%3Fname%3Dtest.md'; + const actualUrl = getFileUrl(workspaceName, filePath); + + expect(actualUrl).toBe(expectedUrl); + }); + + it('handles Unicode characters', () => { + const workspaceName = 'プロジェクト'; + const filePath = 'ファイル.md'; + + const expectedUrl = + 'http://localhost:8080/api/v1/workspaces/%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88/files/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.md'; + const actualUrl = getFileUrl(workspaceName, filePath); + + expect(actualUrl).toBe(expectedUrl); + }); + + it('handles nested folder structures', () => { + const workspaceName = 'docs'; + const filePath = 'projects/2024/q1/report.md'; + + const expectedUrl = + 'http://localhost:8080/api/v1/workspaces/docs/files/projects%2F2024%2Fq1%2Freport.md'; + const actualUrl = getFileUrl(workspaceName, filePath); + + expect(actualUrl).toBe(expectedUrl); + }); + + it('handles edge cases with empty strings', () => { + expect(getFileUrl('', '')).toBe( + 'http://localhost:8080/api/v1/workspaces//files/' + ); + expect(getFileUrl('workspace', '')).toBe( + 'http://localhost:8080/api/v1/workspaces/workspace/files/' + ); + expect(getFileUrl('', 'file.md')).toBe( + 'http://localhost:8080/api/v1/workspaces//files/file.md' + ); + }); + + it('uses the API base URL correctly', () => { + const url = getFileUrl('test', 'file.md'); + expect(url).toBe( + 'http://localhost:8080/api/v1/workspaces/test/files/file.md' + ); + expect(url).toContain(window.API_BASE_URL); + }); + }); +}); diff --git a/app/src/utils/formatBytes.test.ts b/app/src/utils/formatBytes.test.ts new file mode 100644 index 0000000..8affcd7 --- /dev/null +++ b/app/src/utils/formatBytes.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { formatBytes } from './formatBytes'; + +describe('formatBytes', () => { + describe('bytes formatting', () => { + it('formats small byte values correctly', () => { + expect(formatBytes(0)).toBe('0.0 B'); + expect(formatBytes(1)).toBe('1.0 B'); + expect(formatBytes(512)).toBe('512.0 B'); + expect(formatBytes(1023)).toBe('1023.0 B'); + }); + }); + + describe('kilobytes formatting', () => { + it('formats kilobyte values correctly', () => { + expect(formatBytes(1024)).toBe('1.0 KB'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(2048)).toBe('2.0 KB'); + expect(formatBytes(5120)).toBe('5.0 KB'); + expect(formatBytes(1048575)).toBe('1024.0 KB'); // Just under 1MB + }); + + it('handles fractional kilobytes', () => { + expect(formatBytes(1433)).toBe('1.4 KB'); + expect(formatBytes(1587)).toBe('1.5 KB'); + expect(formatBytes(1741)).toBe('1.7 KB'); + }); + }); + + describe('megabytes formatting', () => { + it('formats megabyte values correctly', () => { + expect(formatBytes(1048576)).toBe('1.0 MB'); // 1024^2 + expect(formatBytes(1572864)).toBe('1.5 MB'); + expect(formatBytes(2097152)).toBe('2.0 MB'); + expect(formatBytes(5242880)).toBe('5.0 MB'); + expect(formatBytes(1073741823)).toBe('1024.0 MB'); // Just under 1GB + }); + + it('handles fractional megabytes', () => { + expect(formatBytes(1638400)).toBe('1.6 MB'); + expect(formatBytes(2621440)).toBe('2.5 MB'); + expect(formatBytes(10485760)).toBe('10.0 MB'); + }); + }); + + describe('gigabytes formatting', () => { + it('formats gigabyte values correctly', () => { + expect(formatBytes(1073741824)).toBe('1.0 GB'); // 1024^3 + expect(formatBytes(1610612736)).toBe('1.5 GB'); + expect(formatBytes(2147483648)).toBe('2.0 GB'); + expect(formatBytes(5368709120)).toBe('5.0 GB'); + }); + + it('handles fractional gigabytes', () => { + expect(formatBytes(1288490188.8)).toBe('1.2 GB'); + expect(formatBytes(3221225472)).toBe('3.0 GB'); + expect(formatBytes(10737418240)).toBe('10.0 GB'); + }); + + it('handles very large gigabyte values', () => { + expect(formatBytes(1099511627776)).toBe('1024.0 GB'); // 1TB but capped at GB + expect(formatBytes(2199023255552)).toBe('2048.0 GB'); // 2TB but capped at GB + }); + }); + + describe('decimal precision', () => { + it('always shows one decimal place', () => { + expect(formatBytes(1024)).toBe('1.0 KB'); + expect(formatBytes(1536)).toBe('1.5 KB'); + expect(formatBytes(1048576)).toBe('1.0 MB'); + expect(formatBytes(1073741824)).toBe('1.0 GB'); + }); + + it('rounds to one decimal place correctly', () => { + expect(formatBytes(1126)).toBe('1.1 KB'); // 1126 / 1024 = 1.099... + expect(formatBytes(1177)).toBe('1.1 KB'); // 1177 / 1024 = 1.149... + expect(formatBytes(1229)).toBe('1.2 KB'); // 1229 / 1024 = 1.200... + }); + }); + + describe('edge cases', () => { + it('handles exact unit boundaries', () => { + expect(formatBytes(1024)).toBe('1.0 KB'); + expect(formatBytes(1048576)).toBe('1.0 MB'); + expect(formatBytes(1073741824)).toBe('1.0 GB'); + }); + + it('handles very small decimal values', () => { + expect(formatBytes(0.1)).toBe('0.1 B'); + expect(formatBytes(0.9)).toBe('0.9 B'); + }); + + it('handles negative values (edge case)', () => { + expect(() => formatBytes(-1024)).toThrowError( + 'Byte size cannot be negative' + ); + expect(() => formatBytes(-1048576)).toThrowError( + 'Byte size cannot be negative' + ); + }); + + it('handles extremely large values', () => { + const largeValue = Number.MAX_SAFE_INTEGER; + const result = formatBytes(largeValue); + expect(result).toContain('GB'); + expect(result).toMatch(/^\d+\.\d GB$/); + }); + }); + + describe('unit progression', () => { + it('uses the correct unit for each range', () => { + expect(formatBytes(500)).toContain('B'); + expect(formatBytes(5000)).toContain('KB'); + expect(formatBytes(5000000)).toContain('MB'); + expect(formatBytes(5000000000)).toContain('GB'); + }); + + it('stops at GB unit (does not go to TB)', () => { + const oneTerabyte = 1024 * 1024 * 1024 * 1024; + expect(formatBytes(oneTerabyte)).toContain('GB'); + expect(formatBytes(oneTerabyte)).not.toContain('TB'); + }); + }); +}); diff --git a/app/src/utils/formatBytes.ts b/app/src/utils/formatBytes.ts index 595c61a..3c4cf6a 100644 --- a/app/src/utils/formatBytes.ts +++ b/app/src/utils/formatBytes.ts @@ -16,6 +16,9 @@ const UNITS: readonly ByteUnit[] = ['B', 'KB', 'MB', 'GB'] as const; export const formatBytes = (bytes: number): string => { let size: number = bytes; let unitIndex: number = 0; + if (size < 0) { + throw new Error('Byte size cannot be negative'); + } while (size >= 1024 && unitIndex < UNITS.length - 1) { size /= 1024; unitIndex++; diff --git a/app/src/utils/remarkWikiLinks.test.ts b/app/src/utils/remarkWikiLinks.test.ts new file mode 100644 index 0000000..113c645 --- /dev/null +++ b/app/src/utils/remarkWikiLinks.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkStringify from 'remark-stringify'; +import { remarkWikiLinks } from './remarkWikiLinks'; +import * as fileApi from '@/api/file'; + +// Mock the file API +vi.mock('@/api/file'); + +// Mock window.API_BASE_URL +const mockApiBaseUrl = 'http://localhost:8080/api/v1'; + +describe('remarkWikiLinks', () => { + beforeEach(() => { + window.API_BASE_URL = mockApiBaseUrl; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createProcessor = (workspaceName: string) => { + return unified() + .use(remarkParse) + .use(remarkWikiLinks, workspaceName) + .use(remarkStringify); + }; + + describe('basic wiki link processing', () => { + it('converts existing file links correctly', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['docs/test.md']); + + const processor = createProcessor('test-workspace'); + const markdown = 'Check out [[test]] for more info.'; + + const result = await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'test-workspace', + 'test.md' + ); + expect(result.toString()).toContain('test'); + }); + + it('handles non-existent files with not found links', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue([]); + + const processor = createProcessor('test-workspace'); + const markdown = 'This [[nonexistent]] file does not exist.'; + + const result = await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'test-workspace', + 'nonexistent.md' + ); + expect(result.toString()).toContain('nonexistent'); + }); + + it('handles API errors gracefully', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockRejectedValue(new Error('API Error')); + + const processor = createProcessor('test-workspace'); + const markdown = 'This [[error-file]] causes an error.'; + + // Should not throw + const result = await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'test-workspace', + 'error-file.md' + ); + expect(result.toString()).toContain('error-file'); + }); + }); + + describe('wiki link syntax variations', () => { + it('handles basic wiki links', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['basic.md']); + + const processor = createProcessor('workspace'); + const markdown = '[[basic]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'workspace', + 'basic.md' + ); + }); + + it('handles wiki links with display text', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['file.md']); + + const processor = createProcessor('workspace'); + const markdown = '[[file|Display Text]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith('workspace', 'file.md'); + }); + + it('handles wiki links with headings', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['file.md']); + + const processor = createProcessor('workspace'); + const markdown = '[[file#section]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith('workspace', 'file.md'); + }); + + it('handles wiki links with both headings and display text', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['file.md']); + + const processor = createProcessor('workspace'); + const markdown = '[[file#section|Custom Display]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith('workspace', 'file.md'); + }); + + it('handles image wiki links', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['image.png']); + + const processor = createProcessor('workspace'); + const markdown = '![[image.png]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'workspace', + 'image.png' + ); + }); + + it('handles image wiki links with alt text', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['photo.jpg']); + + const processor = createProcessor('workspace'); + const markdown = '![[photo.jpg|Alt text for photo]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'workspace', + 'photo.jpg' + ); + }); + }); + + describe('file extension handling', () => { + it('adds .md extension to files without extensions', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['notes.md']); + + const processor = createProcessor('workspace'); + const markdown = '[[notes]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'workspace', + 'notes.md' + ); + }); + + it('preserves existing file extensions', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['document.txt']); + + const processor = createProcessor('workspace'); + const markdown = '[[document.txt]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'workspace', + 'document.txt' + ); + }); + + it('handles image files without adding .md extension', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['screenshot.png']); + + const processor = createProcessor('workspace'); + const markdown = '![[screenshot.png]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'workspace', + 'screenshot.png' + ); + }); + }); + + describe('multiple wiki links', () => { + it('processes multiple wiki links in the same text', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName + .mockResolvedValueOnce(['first.md']) + .mockResolvedValueOnce(['second.md']); + + const processor = createProcessor('workspace'); + const markdown = 'See [[first]] and [[second]] for details.'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledTimes(2); + expect(mockLookupFileByName).toHaveBeenNthCalledWith( + 1, + 'workspace', + 'first.md' + ); + expect(mockLookupFileByName).toHaveBeenNthCalledWith( + 2, + 'workspace', + 'second.md' + ); + }); + + it('handles mix of existing and non-existing files', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName + .mockResolvedValueOnce(['exists.md']) + .mockResolvedValueOnce([]); + + const processor = createProcessor('workspace'); + const markdown = 'Check [[exists]] but not [[missing]].'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledTimes(2); + expect(mockLookupFileByName).toHaveBeenNthCalledWith( + 1, + 'workspace', + 'exists.md' + ); + expect(mockLookupFileByName).toHaveBeenNthCalledWith( + 2, + 'workspace', + 'missing.md' + ); + }); + }); + + describe('edge cases', () => { + it('handles text without wiki links', async () => { + const processor = createProcessor('workspace'); + const markdown = 'Just regular text with no wiki links.'; + + const result = await processor.process(markdown); + + expect(result.toString()).toBe('Just regular text with no wiki links.\n'); + expect(fileApi.lookupFileByName).not.toHaveBeenCalled(); + }); + + it('handles wiki links with only spaces', async () => { + const processor = createProcessor('workspace'); + const markdown = 'Spaces [[ ]] link.'; + + const result = await processor.process(markdown); + + expect(result.toString()).toContain('Spaces'); + // Should not call API for empty/whitespace-only links + expect(fileApi.lookupFileByName).not.toHaveBeenCalled(); + }); + + it('handles nested brackets', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['test.md']); + + const processor = createProcessor('workspace'); + const markdown = '[[test]] and some [regular](link) text.'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith('workspace', 'test.md'); + }); + + it('handles special characters in file names', async () => { + const mockLookupFileByName = vi.mocked(fileApi.lookupFileByName); + mockLookupFileByName.mockResolvedValue(['file with spaces & symbols.md']); + + const processor = createProcessor('workspace'); + const markdown = '[[file with spaces & symbols]]'; + + await processor.process(markdown); + + expect(mockLookupFileByName).toHaveBeenCalledWith( + 'workspace', + 'file with spaces & symbols.md' + ); + }); + }); + + describe('workspace handling', () => { + it('handles empty workspace name gracefully', async () => { + const processor = createProcessor(''); + const markdown = '[[test]]'; + + const result = await processor.process(markdown); + + expect(result.toString()).toContain('test'); + // Should not call API when workspace is empty + expect(fileApi.lookupFileByName).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/src/utils/remarkWikiLinks.ts b/app/src/utils/remarkWikiLinks.ts index 43b3a1d..8a5fbe8 100644 --- a/app/src/utils/remarkWikiLinks.ts +++ b/app/src/utils/remarkWikiLinks.ts @@ -233,6 +233,13 @@ export function remarkWikiLinks(workspaceName: string) { } try { + // Skip API call for empty or whitespace-only filenames + if (!match.fileName.trim()) { + newNodes.push(createTextNode(match.fullMatch)); + lastIndex = match.index + match.fullMatch.length; + continue; + } + const lookupFileName: string = match.isImage ? match.fileName : addMarkdownExtension(match.fileName); diff --git a/app/src/utils/themeStyle.test.ts b/app/src/utils/themeStyle.test.ts new file mode 100644 index 0000000..66bd3e6 --- /dev/null +++ b/app/src/utils/themeStyle.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import type { MantineTheme } from '@mantine/core'; +import { + getHoverStyle, + getConditionalColor, + getAccordionStyles, + getWorkspacePaperStyle, + getTextColor, +} from './themeStyles'; + +// Create partial mock themes with only the properties we need +const createMockTheme = (colorScheme: 'light' | 'dark') => ({ + radius: { sm: '4px' }, + spacing: { md: '16px' }, + colors: { + // Use different color values for light and dark themes + // so we can test that different themes produce different results + dark: Array(10) + .fill(0) + .map((_, i) => + colorScheme === 'light' ? `#dark-light-${i}` : `#dark-dark-${i}` + ), + gray: Array(10) + .fill(0) + .map((_, i) => + colorScheme === 'light' ? `#gray-light-${i}` : `#gray-dark-${i}` + ), + blue: Array(10) + .fill(0) + .map((_, i) => + colorScheme === 'light' ? `#blue-light-${i}` : `#blue-dark-${i}` + ), + }, + colorScheme, +}); + +const mockLightTheme = createMockTheme('light') as unknown as MantineTheme; +const mockDarkTheme = createMockTheme('dark') as unknown as MantineTheme; + +describe('themeStyles utilities', () => { + describe('getHoverStyle', () => { + it('returns hover styles with theme-appropriate values', () => { + const lightResult = getHoverStyle(mockLightTheme); + const darkResult = getHoverStyle(mockDarkTheme); + + // Test structure is correct + expect(lightResult).toHaveProperty('borderRadius'); + expect(lightResult).toHaveProperty('&:hover.backgroundColor'); + expect(darkResult).toHaveProperty('borderRadius'); + expect(darkResult).toHaveProperty('&:hover.backgroundColor'); + + // Both themes should use the small border radius + expect(lightResult.borderRadius).toBe(mockLightTheme.radius.sm); + expect(darkResult.borderRadius).toBe(mockDarkTheme.radius.sm); + + // Dark and light themes should have different hover colors + expect(lightResult['&:hover'].backgroundColor).not.toBe( + darkResult['&:hover'].backgroundColor + ); + }); + }); + + describe('getConditionalColor', () => { + it('returns theme-specific colors when selected', () => { + // Test behavior, not specific hex values + const lightResult = getConditionalColor(mockLightTheme, true); + const darkResult = getConditionalColor(mockDarkTheme, true); + + // Different colors for different themes + expect(lightResult).not.toBe(darkResult); + // Not using the fallback value + expect(lightResult).not.toBe('dimmed'); + expect(darkResult).not.toBe('dimmed'); + // Should be a color string + expect(typeof lightResult).toBe('string'); + expect(typeof darkResult).toBe('string'); + }); + + it('returns dimmed when not selected', () => { + expect(getConditionalColor(mockLightTheme, false)).toBe('dimmed'); + expect(getConditionalColor(mockDarkTheme, false)).toBe('dimmed'); + }); + + it('defaults to dimmed when no selection parameter provided', () => { + expect(getConditionalColor(mockLightTheme)).toBe('dimmed'); + expect(getConditionalColor(mockDarkTheme)).toBe('dimmed'); + }); + }); + + describe('getAccordionStyles', () => { + it('returns theme-appropriate accordion styles', () => { + const lightResult = getAccordionStyles(mockLightTheme); + const darkResult = getAccordionStyles(mockDarkTheme); + + // Test structure is correct + expect(lightResult.control).toHaveProperty('paddingTop'); + expect(lightResult.control).toHaveProperty('paddingBottom'); + expect(lightResult.item).toHaveProperty('borderBottom'); + expect(lightResult.item['&[data-active]']).toHaveProperty( + 'backgroundColor' + ); + + // Padding should use theme spacing + expect(lightResult.control.paddingTop).toBe(mockLightTheme.spacing.md); + expect(lightResult.control.paddingBottom).toBe(mockLightTheme.spacing.md); + + // Active state should have different background colors in different themes + expect(lightResult.item['&[data-active]'].backgroundColor).not.toBe( + darkResult.item['&[data-active]'].backgroundColor + ); + }); + }); + + describe('getWorkspacePaperStyle', () => { + it('returns theme-appropriate styles when selected', () => { + const lightResult = getWorkspacePaperStyle(mockLightTheme, true); + const darkResult = getWorkspacePaperStyle(mockDarkTheme, true); + + // Test structure is correct + expect(lightResult).toHaveProperty('backgroundColor'); + expect(lightResult).toHaveProperty('borderColor'); + expect(darkResult).toHaveProperty('backgroundColor'); + expect(darkResult).toHaveProperty('borderColor'); + + // Different themes should use different colors + expect(lightResult.backgroundColor).not.toBe(darkResult.backgroundColor); + expect(lightResult.borderColor).not.toBe(darkResult.borderColor); + }); + + it('returns undefined styles when not selected', () => { + const result = getWorkspacePaperStyle(mockLightTheme, false); + + expect(result.backgroundColor).toBeUndefined(); + expect(result.borderColor).toBeUndefined(); + }); + }); + + describe('getTextColor', () => { + it('returns theme-dependent color when selected', () => { + const lightResult = getTextColor(mockLightTheme, true); + const darkResult = getTextColor(mockDarkTheme, true); + + // Should return a string for selected state + expect(typeof lightResult).toBe('string'); + expect(typeof darkResult).toBe('string'); + + // Different themes should have different text colors + expect(lightResult).not.toBe(darkResult); + }); + + it('returns null when not selected', () => { + expect(getTextColor(mockLightTheme, false)).toBeNull(); + expect(getTextColor(mockDarkTheme, false)).toBeNull(); + }); + }); +}); diff --git a/app/tsconfig.json b/app/tsconfig.json index 36b0596..2e90967 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -53,7 +53,7 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx", "vitest.config.ts"], "exclude": ["node_modules", "dist"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/app/vitest.config.ts b/app/vitest.config.ts new file mode 100644 index 0000000..8954b44 --- /dev/null +++ b/app/vitest.config.ts @@ -0,0 +1,21 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + define: { + 'window.API_BASE_URL': JSON.stringify('http://localhost:8080/api/v1'), + }, +});