diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..d8fe6f1 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npm run test:run diff --git a/package-lock.json b/package-lock.json index 2c68e35..15adab2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -52,11 +53,13 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "^4.0.6", "eslint": "^9.39.0", "eslint-config-next": "16.0.1", "husky": "^9.1.7", "jsdom": "^27.1.0", "lint-staged": "^16.2.6", + "msw": "^2.13.6", "prettier": "^3.6.2", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", @@ -1183,6 +1186,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/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -2493,6 +2506,93 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz", + "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2543,6 +2643,31 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.6.tgz", + "integrity": "sha512-qmDvJIjcNsZ6tXWy2G9yuCgMPTTn35GMA3dPpSLm7QJVpbQzYdw0ALy1bKoivXnEM3U93/OrK+/M719b+fg84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2748,6 +2873,31 @@ "node": ">=12.4.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@prisma/client": { "version": "6.19.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", @@ -5726,6 +5876,43 @@ "tailwindcss": "4.1.16" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/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/@testing-library/dom/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" + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -6246,6 +6433,13 @@ "tslib": "^2.4.0" } }, + "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" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -6393,6 +6587,23 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -6964,6 +7175,38 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.6.tgz", + "integrity": "sha512-cv6pFXj9/Otk7q1Ocoj8k3BUVVwnFr3jqcqpwYrU5LkKClU9DpaMEdX+zptx/RyIJS+/VpoxMWmfISXchmVDPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.6", + "ast-v8-to-istanbul": "^0.3.5", + "debug": "^4.4.3", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.6", + "vitest": "4.0.6" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.6.tgz", @@ -7375,6 +7618,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7742,12 +8004,110 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/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/cliui/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/cliui/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/cliui/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/cliui/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/cliui/node_modules/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/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -7817,12 +8177,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/country-flag-icons": { @@ -8053,6 +8417,16 @@ "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -8982,6 +9356,33 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", + "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fast-xml-builder": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", @@ -9203,6 +9604,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -9382,6 +9793,16 @@ "dev": true, "license": "MIT" }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -9476,6 +9897,17 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -9506,6 +9938,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", @@ -9905,6 +10344,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -10098,6 +10544,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.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -10690,6 +11190,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -10700,6 +11210,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-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.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -10845,6 +11396,61 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.13.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.6.tgz", + "integrity": "sha512-GAJbQy8Ra/Ydjt0Hb2MGT2qhzd83J3+QZMHdH85uW7r/XkKc846+Ma2PLif5hGvTm5Yqa+wkcstpim0WeLZU9g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.7", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -11208,6 +11814,13 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -11327,6 +11940,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -11447,6 +12067,51 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "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", + "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-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/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", + "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" + }, "node_modules/prisma": { "version": "6.19.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", @@ -11947,6 +12612,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -12015,6 +12690,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rettime": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.8.tgz", + "integrity": "sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -12199,6 +12881,13 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -12488,6 +13177,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -12509,6 +13208,13 @@ "node": ">= 0.4" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -12770,6 +13476,19 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwind-merge": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", @@ -12876,9 +13595,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12969,6 +13688,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -13152,6 +13887,16 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -13691,6 +14436,16 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -13714,6 +14469,90 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/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/yargs/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/yargs/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/yargs/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/yargs/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/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a3025a6..c5849ef 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "prepare": "husky", "prettier:check": "prettier --check .", "prettier:fix": "prettier --write .", - "test": "vitest" + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@prisma/client": "^6.19.0", @@ -49,6 +51,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -57,11 +60,13 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.0", + "@vitest/coverage-v8": "^4.0.6", "eslint": "^9.39.0", "eslint-config-next": "16.0.1", "husky": "^9.1.7", "jsdom": "^27.1.0", "lint-staged": "^16.2.6", + "msw": "^2.13.6", "prettier": "^3.6.2", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", diff --git a/src/components/test/placeholder.test.ts b/src/components/test/placeholder.test.ts deleted file mode 100644 index 7fd10c7..0000000 --- a/src/components/test/placeholder.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from "vitest"; - -describe("Placeholder CI Test", () => { - it("should always pass", () => { - expect(true).toBe(true); - }); -}); diff --git a/tests/__mocks__/prisma.ts b/tests/__mocks__/prisma.ts new file mode 100644 index 0000000..7dac3f2 --- /dev/null +++ b/tests/__mocks__/prisma.ts @@ -0,0 +1,24 @@ +import { vi } from "vitest"; + +export const mockPrisma = { + manager: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + user: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + employee: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + $disconnect: vi.fn(), +}; diff --git a/tests/__mocks__/supabase.ts b/tests/__mocks__/supabase.ts new file mode 100644 index 0000000..8e0209a --- /dev/null +++ b/tests/__mocks__/supabase.ts @@ -0,0 +1,28 @@ +import { vi } from "vitest"; + +export const mockAuth = { + signInWithPassword: vi.fn(), + signUp: vi.fn(), + signOut: vi.fn(), +}; + +export const mockStorage = { + from: vi.fn().mockReturnValue({ + upload: vi.fn(), + remove: vi.fn(), + getPublicUrl: vi + .fn() + .mockReturnValue({ data: { publicUrl: "https://example.com/avatar.jpg" } }), + }), +}; + +export const mockSupabaseClient = { + auth: mockAuth, + storage: mockStorage, +}; + +// Used by vi.mock('@/src/lib/supabase/server', ...) +export const createClient = vi.fn().mockResolvedValue(mockSupabaseClient); + +// Used by vi.mock('@/src/lib/supabase/client', ...) +export const createClientClient = vi.fn().mockReturnValue(mockSupabaseClient); diff --git a/tests/api/auth/login.test.ts b/tests/api/auth/login.test.ts new file mode 100644 index 0000000..a41cb8f --- /dev/null +++ b/tests/api/auth/login.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/src/app/api/auth/login/route"; + +vi.mock("@/src/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +import { createClient } from "@/src/lib/supabase/server"; + +const mockSignInWithPassword = vi.fn(); +const mockSupabase = { auth: { signInWithPassword: mockSignInWithPassword } }; + +beforeEach(() => { + vi.mocked(createClient).mockResolvedValue(mockSupabase as never); + mockSignInWithPassword.mockReset(); +}); + +function makeRequest(body: Record) { + return new Request("http://localhost/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/auth/login", () => { + it("gibt 400 zurück wenn E-Mail fehlt", async () => { + const res = await POST(makeRequest({ password: "passwort123" })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); + + it("gibt 400 zurück wenn Passwort fehlt", async () => { + const res = await POST(makeRequest({ email: "test@example.com" })); + expect(res.status).toBe(400); + }); + + it("gibt 401 zurück bei falschen Credentials", async () => { + mockSignInWithPassword.mockResolvedValue({ + data: { user: null, session: null }, + error: { message: "Invalid login credentials" }, + }); + + const res = await POST(makeRequest({ email: "test@example.com", password: "falsch123" })); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toContain("falsch"); + }); + + it("gibt 200 und User/Session zurück bei Erfolg", async () => { + const fakeUser = { id: "user-1", email: "test@example.com" }; + const fakeSession = { access_token: "token-abc" }; + mockSignInWithPassword.mockResolvedValue({ + data: { user: fakeUser, session: fakeSession }, + error: null, + }); + + const res = await POST(makeRequest({ email: "test@example.com", password: "Richtig!123" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.user.id).toBe("user-1"); + expect(data.session.access_token).toBe("token-abc"); + }); +}); diff --git a/tests/api/auth/register-employee.test.ts b/tests/api/auth/register-employee.test.ts new file mode 100644 index 0000000..f102610 --- /dev/null +++ b/tests/api/auth/register-employee.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/src/app/api/auth/register/employee/route"; + +vi.mock("@/src/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/src/lib/prisma", () => ({ + prisma: { + employee: { findUnique: vi.fn() }, + }, +})); + +import { createClient } from "@/src/lib/supabase/server"; +import { prisma } from "@/src/lib/prisma"; + +const mockSignUp = vi.fn(); +const mockSupabase = { auth: { signUp: mockSignUp } }; + +const validToken = "valid-token-abc"; +const validEmployee = { + email: "employee@example.com", + isOnboarded: false, + onboardingTokenExpiry: new Date(Date.now() + 3600000), +}; + +beforeEach(() => { + vi.mocked(createClient).mockResolvedValue(mockSupabase as never); + vi.mocked(prisma.employee.findUnique).mockReset(); + mockSignUp.mockReset(); +}); + +const validBody = { + token: validToken, + email: "employee@example.com", + password: "Sicher!123", + firstName: "Anna", + lastName: "Schmidt", +}; + +let ipCounter = 0; + +function makeRequest(body: Record) { + // Each test gets a unique IP to avoid triggering the in-memory rate limiter + const ip = `10.0.0.${++ipCounter}`; + return new Request("http://localhost/api/auth/register/employee", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-forwarded-for": ip, + }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/auth/register/employee", () => { + it("gibt 400 zurück wenn Pflichtfelder fehlen", async () => { + const res = await POST(makeRequest({ token: validToken })); + expect(res.status).toBe(400); + }); + + it("gibt 401 zurück bei ungültigem Token", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue(null); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toContain("Ungültiger Token"); + }); + + it("gibt 401 zurück bei bereits abgeschlossenem Onboarding", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue({ + ...validEmployee, + isOnboarded: true, + } as never); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(401); + }); + + it("gibt 401 zurück bei falschem E-Mail im Token", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue({ + ...validEmployee, + email: "andere@example.com", + } as never); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(401); + }); + + it("gibt 401 zurück bei abgelaufenem Token", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue({ + ...validEmployee, + onboardingTokenExpiry: new Date(Date.now() - 1000), + } as never); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toContain("abgelaufen"); + }); + + it("gibt 400 zurück bei schwachem Passwort", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never); + + const res = await POST(makeRequest({ ...validBody, password: "schwach" })); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Passwort"); + }); + + it("gibt 201 zurück bei erfolgreicher Registrierung", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never); + mockSignUp.mockResolvedValue({ + data: { user: { id: "emp-1" } }, + error: null, + }); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.success).toBe(true); + }); +}); diff --git a/tests/api/auth/register.test.ts b/tests/api/auth/register.test.ts new file mode 100644 index 0000000..85e638a --- /dev/null +++ b/tests/api/auth/register.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/src/app/api/auth/register/route"; + +vi.mock("@/src/lib/supabase/server", () => ({ + createClient: vi.fn(), +})); + +vi.mock("@/src/lib/prisma", () => ({ + prisma: { + manager: { findUnique: vi.fn() }, + user: { findUnique: vi.fn(), create: vi.fn() }, + }, +})); + +import { createClient } from "@/src/lib/supabase/server"; +import { prisma } from "@/src/lib/prisma"; + +const mockSignUp = vi.fn(); +const mockSupabase = { auth: { signUp: mockSignUp } }; + +beforeEach(() => { + vi.mocked(createClient).mockResolvedValue(mockSupabase as never); + vi.mocked(prisma.manager.findUnique).mockReset(); + vi.mocked(prisma.user.findUnique).mockReset(); + vi.mocked(prisma.user.create).mockReset(); + mockSignUp.mockReset(); +}); + +const validBody = { + email: "neu@example.com", + password: "Sicher!123", + name: "Max Mustermann", + birthday: "1990-05-15", + plz: "10115", + city: "Berlin", + street: "Musterstraße 1", + houseNumber: "1", + gender: "Men", +}; + +function makeRequest(body: Record) { + return new Request("http://localhost/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +describe("POST /api/auth/register", () => { + it("gibt 400 zurück wenn Pflichtfelder fehlen", async () => { + const res = await POST(makeRequest({ email: "test@example.com" })); + expect(res.status).toBe(400); + }); + + it("gibt 409 zurück wenn E-Mail bereits als Manager existiert", async () => { + vi.mocked(prisma.manager.findUnique).mockResolvedValue({ + id: "m-1", + email: validBody.email, + } as never); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.error).toContain("Manager"); + }); + + it("gibt 409 zurück wenn User bereits existiert", async () => { + vi.mocked(prisma.manager.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "u-1", + email: validBody.email, + } as never); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(409); + const data = await res.json(); + expect(data.error).toContain("existiert bereits"); + }); + + it("gibt 400 zurück bei Supabase-Auth-Fehler", async () => { + vi.mocked(prisma.manager.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + mockSignUp.mockResolvedValue({ + data: { user: null }, + error: { message: "Email rate limit exceeded" }, + }); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(400); + }); + + it("gibt 201 und neuen User zurück bei Erfolg", async () => { + const fakeUser = { id: "new-1" }; + const createdUser = { id: "new-1", email: validBody.email, name: validBody.name }; + + vi.mocked(prisma.manager.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + mockSignUp.mockResolvedValue({ data: { user: fakeUser }, error: null }); + vi.mocked(prisma.user.create).mockResolvedValue(createdUser as never); + + const res = await POST(makeRequest(validBody)); + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.user.email).toBe(validBody.email); + }); +}); diff --git a/tests/api/upload/avatar.test.ts b/tests/api/upload/avatar.test.ts new file mode 100644 index 0000000..9eb4cae --- /dev/null +++ b/tests/api/upload/avatar.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/src/app/api/upload/avatar/route"; + +vi.mock("@/src/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(), +})); + +vi.mock("@/src/lib/prisma", () => ({ + prisma: { + employee: { findUnique: vi.fn() }, + }, +})); + +import { createAdminClient } from "@/src/lib/supabase/admin"; +import { prisma } from "@/src/lib/prisma"; + +const mockUpload = vi.fn(); +const mockGetPublicUrl = vi.fn().mockReturnValue({ + data: { publicUrl: "https://example.com/avatars/emp-1/photo.jpg" }, +}); +const mockStorageBucket = { upload: mockUpload, getPublicUrl: mockGetPublicUrl }; +const mockAdminClient = { storage: { from: vi.fn().mockReturnValue(mockStorageBucket) } }; + +const validEmployee = { + id: "emp-1", + isOnboarded: false, + onboardingTokenExpiry: new Date(Date.now() + 3600000), +}; + +beforeEach(() => { + vi.mocked(createAdminClient).mockReturnValue(mockAdminClient as never); + vi.mocked(prisma.employee.findUnique).mockReset(); + mockUpload.mockReset(); + mockUpload.mockResolvedValue({ data: { path: "employees/emp-1/photo.jpg" }, error: null }); +}); + +function makeImageFile(name = "photo.jpg", type = "image/jpeg", sizeBytes = 1024): File { + const content = new Uint8Array(sizeBytes); + const file = new File([content], name, { type }); + // Ensure arrayBuffer() is available (jsdom File may lack it in Node.js test env) + if (typeof file.arrayBuffer !== "function") { + Object.defineProperty(file, "arrayBuffer", { + value: async () => content.buffer, + }); + } + return file; +} + +// jsdom's multipart body parser drops File.type, so we mock formData() directly +// to return a FormData containing the original File object unchanged. +function makeRequest(file: File | null, token: string | null): Request { + const req = new Request("http://localhost/api/upload/avatar", { method: "POST" }); + + req.formData = async () => { + const fd = { + get(key: string) { + if (key === "file") return file; + if (key === "token") return token; + return null; + }, + } as unknown as FormData; + return fd; + }; + + return req; +} + +describe("POST /api/upload/avatar", () => { + it("gibt 400 zurück wenn Datei fehlt", async () => { + const res = await POST(makeRequest(null, "valid-token")); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Token"); + }); + + it("gibt 400 zurück wenn Token fehlt", async () => { + const res = await POST(makeRequest(makeImageFile(), null)); + expect(res.status).toBe(400); + }); + + it("gibt 401 zurück bei ungültigem Token", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue(null); + + const res = await POST(makeRequest(makeImageFile(), "invalid-token")); + expect(res.status).toBe(401); + }); + + it("gibt 401 zurück bei bereits abgeschlossenem Onboarding", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue({ + ...validEmployee, + isOnboarded: true, + } as never); + + const res = await POST(makeRequest(makeImageFile(), "valid-token")); + expect(res.status).toBe(401); + }); + + it("gibt 401 zurück bei abgelaufenem Token", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue({ + ...validEmployee, + onboardingTokenExpiry: new Date(Date.now() - 1000), + } as never); + + const res = await POST(makeRequest(makeImageFile(), "valid-token")); + expect(res.status).toBe(401); + }); + + it("gibt 400 zurück bei nicht-Bilddatei", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never); + + const pdfFile = new File(["pdf content"], "doc.pdf", { type: "application/pdf" }); + const res = await POST(makeRequest(pdfFile, "valid-token")); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("Bilddateien"); + }); + + it("gibt 400 zurück bei zu großer Datei (>5MB)", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never); + + const bigFile = makeImageFile("big.jpg", "image/jpeg", 6 * 1024 * 1024); + const res = await POST(makeRequest(bigFile, "valid-token")); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("5MB"); + }); + + it("gibt 201 und publicUrl zurück bei Erfolg", async () => { + vi.mocked(prisma.employee.findUnique).mockResolvedValue(validEmployee as never); + + const res = await POST(makeRequest(makeImageFile(), "valid-token")); + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.publicUrl).toContain("https://"); + }); +}); diff --git a/tests/hooks/useAuth.test.ts b/tests/hooks/useAuth.test.ts new file mode 100644 index 0000000..f8cac3d --- /dev/null +++ b/tests/hooks/useAuth.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: mockRouterPush }), +})); + +vi.mock("@/src/store/authStore", () => ({ + useAuthStore: (selector: (s: { setUser: typeof mockSetUser }) => typeof mockSetUser) => + selector({ setUser: mockSetUser }), +})); + +vi.mock("@/src/lib/supabase/client", () => ({ + createClient: () => ({ auth: { signInWithPassword: mockSupabaseSignIn } }), +})); + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +const mockRouterPush = vi.fn(); +const mockSetUser = vi.fn(); +const mockSupabaseSignIn = vi.fn(); + +import { useAuth } from "@/src/hooks/useAuth"; + +beforeEach(() => { + mockRouterPush.mockReset(); + mockSetUser.mockReset(); + mockSupabaseSignIn.mockReset(); + vi.stubGlobal("fetch", vi.fn()); +}); + +describe("useAuth", () => { + describe("loginUser", () => { + it("gibt Zod-Validierungsfehler bei ungültiger E-Mail zurück", async () => { + const { result } = renderHook(() => useAuth()); + + let success: boolean; + await act(async () => { + success = await result.current.loginUser("keine-email", "passwort123", false); + }); + + expect(success!).toBe(false); + expect(result.current.errors.email).toBeDefined(); + expect(mockSupabaseSignIn).not.toHaveBeenCalled(); + }); + + it("setzt Fehler bei falschen Credentials", async () => { + mockSupabaseSignIn.mockResolvedValue({ + data: { user: null, session: null }, + error: { message: "Invalid credentials" }, + }); + + const { result } = renderHook(() => useAuth()); + + let success: boolean; + await act(async () => { + success = await result.current.loginUser("test@example.com", "Falsch!123", false); + }); + + expect(success!).toBe(false); + expect(result.current.errors.email).toBeDefined(); + expect(mockRouterPush).not.toHaveBeenCalled(); + }); + + it("ruft setUser und router.push bei erfolgreichem Login auf", async () => { + const fakeUser = { id: "user-1", email: "test@example.com" }; + mockSupabaseSignIn.mockResolvedValue({ + data: { user: fakeUser, session: {} }, + error: null, + }); + + const { result } = renderHook(() => useAuth()); + + let success: boolean; + await act(async () => { + success = await result.current.loginUser("test@example.com", "Richtig!123", false); + }); + + expect(success!).toBe(true); + expect(mockSetUser).toHaveBeenCalledWith(fakeUser); + expect(mockRouterPush).toHaveBeenCalledWith("/dashboard"); + }); + + it("setzt loading auf false nach Abschluss", async () => { + mockSupabaseSignIn.mockResolvedValue({ + data: { user: { id: "1" }, session: {} }, + error: null, + }); + + const { result } = renderHook(() => useAuth()); + + await act(async () => { + await result.current.loginUser("test@example.com", "Richtig!123", false); + }); + + expect(result.current.loading).toBe(false); + }); + }); + + describe("registerUser", () => { + const validFormData = { + fullName: "Max Mustermann", + email: "max@example.com", + password: "Sicher!123", + passwordConfirm: "Sicher!123", + birthday: "1990-05-15", + plz: "10115", + place: "Berlin", + street: "Musterstr. 1", + gender: "Men" as const, + }; + + it("gibt Zod-Fehler bei fehlenden Pflichtfeldern zurück", async () => { + const { result } = renderHook(() => useAuth()); + + let success: boolean; + await act(async () => { + success = await result.current.registerUser({ + ...validFormData, + email: "keine-email", + }); + }); + + expect(success!).toBe(false); + expect(result.current.errors.email).toBeDefined(); + }); + + it("gibt true bei erfolgreicher Registrierung zurück", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ user: { id: "new-1" } }), + } as Response); + + const { result } = renderHook(() => useAuth()); + + let success: boolean; + await act(async () => { + success = await result.current.registerUser(validFormData); + }); + + expect(success!).toBe(true); + }); + + it("setzt Fehler bei API-Fehlerantwort", async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + json: async () => ({ error: "Dieser Nutzer existiert bereits." }), + } as Response); + + const { result } = renderHook(() => useAuth()); + + let success: boolean; + await act(async () => { + success = await result.current.registerUser(validFormData); + }); + + expect(success!).toBe(false); + expect(result.current.errors.email).toContain("existiert bereits"); + }); + }); +}); diff --git a/tests/hooks/useImageUpload.test.ts b/tests/hooks/useImageUpload.test.ts new file mode 100644 index 0000000..b2bd47e --- /dev/null +++ b/tests/hooks/useImageUpload.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useImageUpload } from "@/src/hooks/useImageUpload"; + +vi.mock("@/src/lib/supabase/storage", () => ({ + uploadFile: vi.fn(), + deleteFile: vi.fn(), + generateFilePath: vi.fn().mockReturnValue("users/user-1/1234567890.jpg"), +})); + +import { uploadFile, deleteFile } from "@/src/lib/supabase/storage"; + +const defaultOptions = { bucket: "avatars" as const }; + +function makeFile(name = "photo.jpg", type = "image/jpeg", sizeBytes = 1024): File { + return new File([new Uint8Array(sizeBytes)], name, { type }); +} + +beforeEach(() => { + vi.mocked(uploadFile).mockReset(); + vi.mocked(deleteFile).mockReset(); +}); + +describe("useImageUpload", () => { + it("gibt initialen State zurück", () => { + const { result } = renderHook(() => useImageUpload(defaultOptions)); + expect(result.current.isUploading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.progress).toBe(0); + }); + + it("lehnt ungültigen MIME-Type ab und setzt error", async () => { + const { result } = renderHook(() => useImageUpload(defaultOptions)); + const pdfFile = new File(["pdf"], "doc.pdf", { type: "application/pdf" }); + + await act(async () => { + await expect(result.current.upload(pdfFile, "user-1")).rejects.toThrow(); + }); + + expect(result.current.error).toContain("Ungültiger Dateityp"); + expect(result.current.isUploading).toBe(false); + }); + + it("lehnt Datei über maximaler Größe ab und setzt error", async () => { + const { result } = renderHook(() => useImageUpload({ ...defaultOptions, maxSizeMB: 1 })); + const bigFile = makeFile("big.jpg", "image/jpeg", 2 * 1024 * 1024); + + await act(async () => { + await expect(result.current.upload(bigFile, "user-1")).rejects.toThrow(); + }); + + expect(result.current.error).toContain("groß"); + expect(result.current.isUploading).toBe(false); + }); + + it("setzt isUploading auf false nach erfolgreichem Upload", async () => { + vi.mocked(uploadFile).mockResolvedValue({ + path: "users/user-1/photo.jpg", + publicUrl: "https://example.com/photo.jpg", + }); + + const { result } = renderHook(() => useImageUpload(defaultOptions)); + const file = makeFile(); + + let url: string; + await act(async () => { + url = await result.current.upload(file, "user-1"); + }); + + expect(url!).toBe("https://example.com/photo.jpg"); + expect(result.current.isUploading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.progress).toBe(100); + }); + + it("setzt error bei fehlgeschlagenem Upload", async () => { + vi.mocked(uploadFile).mockRejectedValue(new Error("Netzwerkfehler")); + + const { result } = renderHook(() => useImageUpload(defaultOptions)); + + await act(async () => { + await expect(result.current.upload(makeFile(), "user-1")).rejects.toThrow("Netzwerkfehler"); + }); + + expect(result.current.error).toBe("Netzwerkfehler"); + expect(result.current.isUploading).toBe(false); + }); + + it("ruft deleteFile mit korrektem Pfad auf", async () => { + vi.mocked(deleteFile).mockResolvedValue(undefined); + + const { result } = renderHook(() => useImageUpload(defaultOptions)); + + await act(async () => { + await result.current.remove("users/user-1/old-photo.jpg"); + }); + + expect(deleteFile).toHaveBeenCalledWith("avatars", "users/user-1/old-photo.jpg"); + expect(result.current.error).toBeNull(); + }); + + it("setzt error wenn delete fehlschlägt", async () => { + vi.mocked(deleteFile).mockRejectedValue(new Error("Löschen fehlgeschlagen")); + + const { result } = renderHook(() => useImageUpload(defaultOptions)); + + await act(async () => { + await expect(result.current.remove("some/path.jpg")).rejects.toThrow(); + }); + + expect(result.current.error).toBe("Löschen fehlgeschlagen"); + }); + + it("akzeptiert konfigurierbare erlaubte MIME-Types", async () => { + const { result } = renderHook(() => + useImageUpload({ ...defaultOptions, allowedTypes: ["image/png"] }) + ); + + const jpegFile = makeFile("photo.jpg", "image/jpeg"); + + await act(async () => { + await expect(result.current.upload(jpegFile, "user-1")).rejects.toThrow(); + }); + + expect(result.current.error).toContain("Ungültiger Dateityp"); + }); +}); diff --git a/tests/hooks/useIsMobile.test.ts b/tests/hooks/useIsMobile.test.ts new file mode 100644 index 0000000..a8b52d2 --- /dev/null +++ b/tests/hooks/useIsMobile.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { useIsMobile } from "@/src/hooks/use-mobile"; + +const mockAddEventListener = vi.fn(); +const mockRemoveEventListener = vi.fn(); + +function setWindowWidth(width: number) { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: width, + }); + + Object.defineProperty(window, "matchMedia", { + writable: true, + configurable: true, + value: (query: string) => ({ + matches: width < 768, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: mockAddEventListener, + removeEventListener: mockRemoveEventListener, + dispatchEvent: vi.fn(), + }), + }); +} + +beforeEach(() => { + mockAddEventListener.mockClear(); + mockRemoveEventListener.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("useIsMobile", () => { + it("gibt true zurück bei schmaler Viewport-Breite (<768px)", () => { + setWindowWidth(500); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(true); + }); + + it("gibt false zurück bei breiter Viewport-Breite (>=768px)", () => { + setWindowWidth(1024); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + }); + + it("gibt false zurück genau bei 768px", () => { + setWindowWidth(768); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + }); + + it("registriert einen Event-Listener auf matchMedia", () => { + setWindowWidth(500); + renderHook(() => useIsMobile()); + expect(mockAddEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + }); + + it("entfernt den Event-Listener beim Unmount", () => { + setWindowWidth(500); + const { unmount } = renderHook(() => useIsMobile()); + unmount(); + expect(mockRemoveEventListener).toHaveBeenCalledWith("change", expect.any(Function)); + }); +}); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts new file mode 100644 index 0000000..0d94207 --- /dev/null +++ b/tests/msw/handlers.ts @@ -0,0 +1,77 @@ +import { http, HttpResponse } from "msw"; + +export const handlers = [ + http.post("http://localhost/api/auth/login", async ({ request }) => { + const body = (await request.json()) as Record; + + if (!body.email || !body.password) { + return HttpResponse.json({ error: "E-Mail und Passwort sind erforderlich" }, { status: 400 }); + } + + if (body.email === "wrong@example.com") { + return HttpResponse.json({ error: "E-Mail oder Passwort ist falsch" }, { status: 401 }); + } + + return HttpResponse.json( + { + user: { id: "user-1", email: body.email }, + session: { access_token: "token-123" }, + }, + { status: 200 } + ); + }), + + http.post("http://localhost/api/auth/register", async ({ request }) => { + const body = (await request.json()) as Record; + + if (!body.email || !body.password || !body.name) { + return HttpResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); + } + + if (body.email === "existing@example.com") { + return HttpResponse.json({ error: "Dieser Nutzer existiert bereits." }, { status: 409 }); + } + + return HttpResponse.json( + { user: { id: "new-user-1", email: body.email, name: body.name } }, + { status: 201 } + ); + }), + + http.post("http://localhost/api/auth/register/employee", async ({ request }) => { + const body = (await request.json()) as Record; + + if (!body.token || !body.email || !body.password || !body.firstName || !body.lastName) { + return HttpResponse.json({ error: "Pflichtfelder fehlen" }, { status: 400 }); + } + + if (body.token === "expired-token") { + return HttpResponse.json({ error: "Token abgelaufen" }, { status: 401 }); + } + + if (body.token === "invalid-token") { + return HttpResponse.json({ error: "Ungültiger Token" }, { status: 401 }); + } + + return HttpResponse.json({ success: true }, { status: 201 }); + }), + + http.post("http://localhost/api/upload/avatar", async ({ request }) => { + const formData = await request.formData(); + const file = formData.get("file"); + const token = formData.get("token"); + + if (!file || !token) { + return HttpResponse.json({ error: "Datei und Token erforderlich" }, { status: 400 }); + } + + if (token === "invalid-token") { + return HttpResponse.json({ error: "Ungültiger Token" }, { status: 401 }); + } + + return HttpResponse.json( + { publicUrl: "https://example.com/avatars/employee-1/avatar.jpg" }, + { status: 201 } + ); + }), +]; diff --git a/tests/msw/server.ts b/tests/msw/server.ts new file mode 100644 index 0000000..7b37f2a --- /dev/null +++ b/tests/msw/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from "msw/node"; +import { handlers } from "./handlers"; + +export const server = setupServer(...handlers); diff --git a/tests/schemas/courseSchema.test.ts b/tests/schemas/courseSchema.test.ts new file mode 100644 index 0000000..64039f6 --- /dev/null +++ b/tests/schemas/courseSchema.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "vitest"; +import { courseSchema, publishedCourseSchema } from "@/src/modules/courses/schemas/course-schema"; + +describe("courseSchema (Draft)", () => { + it("akzeptiert komplett leere Daten (Entwurf)", () => { + const result = courseSchema.safeParse({ isStandingOrder: false }); + expect(result.success).toBe(true); + }); + + it("akzeptiert leere String-Felder", () => { + const result = courseSchema.safeParse({ + name: "", + date: "", + timeFrom: "", + timeTo: "", + isStandingOrder: false, + }); + expect(result.success).toBe(true); + }); + + it("lehnt timeFrom >= timeTo ab", () => { + const result = courseSchema.safeParse({ + isStandingOrder: false, + timeFrom: "14:00", + timeTo: "10:00", + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("timeTo"); + }); + + it("akzeptiert valide Zeitspanne", () => { + const result = courseSchema.safeParse({ + isStandingOrder: false, + timeFrom: "09:00", + timeTo: "10:30", + }); + expect(result.success).toBe(true); + }); +}); + +const validPublishedCourse = { + name: "Yoga Grundlagen", + sport: ["Yoga"], + level: "Anfänger", + date: "2026-05-01", + timeFrom: "09:00", + timeTo: "10:30", + trainers: ["trainer-1"], + room: "raum-1", + description: "Einführungskurs", + maxParticipants: 15, + price: 19.99, + isStandingOrder: false, +}; + +describe("publishedCourseSchema", () => { + it("akzeptiert vollständig valide Kursdaten", () => { + const result = publishedCourseSchema.safeParse(validPublishedCourse); + expect(result.success).toBe(true); + }); + + it("lehnt fehlenden Kursnamen ab", () => { + const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, name: "" }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("name"); + }); + + it("lehnt leeres Sport-Array ab", () => { + const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, sport: [] }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("sport"); + }); + + it("lehnt leeres Trainer-Array ab", () => { + const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, trainers: [] }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("trainers"); + }); + + it("lehnt maxParticipants über 100 ab", () => { + const result = publishedCourseSchema.safeParse({ + ...validPublishedCourse, + maxParticipants: 101, + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("maxParticipants"); + }); + + it("lehnt maxParticipants unter 1 ab", () => { + const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, maxParticipants: 0 }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("maxParticipants"); + }); + + it("lehnt Preis über 9999.99 ab", () => { + const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, price: 10000 }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("price"); + }); + + it("lehnt negativen Preis ab", () => { + const result = publishedCourseSchema.safeParse({ ...validPublishedCourse, price: -1 }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("price"); + }); + + it("lehnt timeFrom >= timeTo ab", () => { + const result = publishedCourseSchema.safeParse({ + ...validPublishedCourse, + timeFrom: "11:00", + timeTo: "09:00", + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("timeTo"); + }); + + it("lehnt Dauerauftrag ohne Häufigkeit ab", () => { + const result = publishedCourseSchema.safeParse({ + ...validPublishedCourse, + isStandingOrder: true, + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("frequency"); + }); + + it("akzeptiert Dauerauftrag mit Häufigkeit", () => { + const result = publishedCourseSchema.safeParse({ + ...validPublishedCourse, + isStandingOrder: true, + frequency: "weekly", + }); + expect(result.success).toBe(true); + }); + + it("lehnt individuelle Häufigkeit ohne Wochentage ab", () => { + const result = publishedCourseSchema.safeParse({ + ...validPublishedCourse, + isStandingOrder: true, + frequency: "custom", + weekdays: [], + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("weekdays"); + }); + + it("akzeptiert individuelle Häufigkeit mit Wochentagen", () => { + const result = publishedCourseSchema.safeParse({ + ...validPublishedCourse, + isStandingOrder: true, + frequency: "custom", + weekdays: ["Montag", "Mittwoch"], + }); + expect(result.success).toBe(true); + }); +}); diff --git a/tests/schemas/employeeSchema.test.ts b/tests/schemas/employeeSchema.test.ts new file mode 100644 index 0000000..9ecd7f5 --- /dev/null +++ b/tests/schemas/employeeSchema.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { employeeSchema } from "@/src/modules/employee/schemas/employee-schema"; + +const defaultPermissions = { + employees: { view: true, create: false, edit: false, delete: false }, + courses: { view: true, create: false, edit: false, delete: false }, + rooms: { view: true, create: false, edit: false, delete: false }, +}; + +const validData = { + email: "mitarbeiter@example.com", + roles: ["Trainer"], + isMultipleLocation: false, + permissions: defaultPermissions, +}; + +describe("employeeSchema", () => { + it("akzeptiert valide Mitarbeiterdaten", () => { + const result = employeeSchema.safeParse(validData); + expect(result.success).toBe(true); + }); + + it("lehnt ungültige E-Mail ab", () => { + const result = employeeSchema.safeParse({ ...validData, email: "keine-email" }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("email"); + }); + + it("lehnt leeres Rollen-Array ab", () => { + const result = employeeSchema.safeParse({ ...validData, roles: [] }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("roles"); + }); + + it("lehnt mehrere Standorte ohne Standort-Auswahl ab", () => { + const result = employeeSchema.safeParse({ + ...validData, + isMultipleLocation: true, + locations: [], + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("locations"); + }); + + it("akzeptiert mehrere Standorte mit mindestens einem Standort", () => { + const result = employeeSchema.safeParse({ + ...validData, + isMultipleLocation: true, + locations: ["standort-1"], + }); + expect(result.success).toBe(true); + }); + + it("akzeptiert mehrere Rollen", () => { + const result = employeeSchema.safeParse({ + ...validData, + roles: ["Trainer", "Admin", "Kassierer"], + }); + expect(result.success).toBe(true); + }); + + it("akzeptiert alle Permissions als false", () => { + const noPermissions = { + employees: { view: false, create: false, edit: false, delete: false }, + courses: { view: false, create: false, edit: false, delete: false }, + rooms: { view: false, create: false, edit: false, delete: false }, + }; + const result = employeeSchema.safeParse({ ...validData, permissions: noPermissions }); + expect(result.success).toBe(true); + }); +}); diff --git a/tests/schemas/userLoginSchema.test.ts b/tests/schemas/userLoginSchema.test.ts new file mode 100644 index 0000000..b614781 --- /dev/null +++ b/tests/schemas/userLoginSchema.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { userLoginSchema } from "@/src/modules/auth/validation/userLoginSchema"; + +describe("userLoginSchema", () => { + it("akzeptiert valide Anmeldedaten", () => { + const result = userLoginSchema.safeParse({ + email: "test@example.com", + password: "passwort123", + }); + expect(result.success).toBe(true); + }); + + it("lehnt ungültige E-Mail ab", () => { + const result = userLoginSchema.safeParse({ + email: "kein-email", + password: "passwort123", + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("email"); + }); + + it("lehnt Passwort unter 8 Zeichen ab", () => { + const result = userLoginSchema.safeParse({ + email: "test@example.com", + password: "kurz", + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("password"); + }); + + it("lehnt fehlende E-Mail ab", () => { + const result = userLoginSchema.safeParse({ password: "passwort123" }); + expect(result.success).toBe(false); + }); + + it("lehnt fehlendes Passwort ab", () => { + const result = userLoginSchema.safeParse({ email: "test@example.com" }); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/schemas/userRegisterSchema.test.ts b/tests/schemas/userRegisterSchema.test.ts new file mode 100644 index 0000000..17ed598 --- /dev/null +++ b/tests/schemas/userRegisterSchema.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { userRegisterSchema } from "@/src/modules/auth/validation/userRegisterSchema"; + +const validData = { + fullName: "Max Mustermann", + email: "max@example.com", + password: "Sicher!123", + passwordConfirm: "Sicher!123", + birthday: "1990-05-15", + plz: "10115", + place: "Berlin", + street: "Musterstraße 1", + gender: "Men" as const, +}; + +describe("userRegisterSchema", () => { + it("akzeptiert vollständig valide Daten", () => { + const result = userRegisterSchema.safeParse(validData); + expect(result.success).toBe(true); + }); + + it("lehnt zu kurzen Namen ab", () => { + const result = userRegisterSchema.safeParse({ ...validData, fullName: "X" }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("fullName"); + }); + + it("lehnt ungültige E-Mail ab", () => { + const result = userRegisterSchema.safeParse({ ...validData, email: "keine-email" }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("email"); + }); + + it("lehnt Passwort ohne Großbuchstaben ab", () => { + const result = userRegisterSchema.safeParse({ + ...validData, + password: "sicher!123", + passwordConfirm: "sicher!123", + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("password"); + }); + + it("lehnt Passwort ohne Sonderzeichen ab", () => { + const result = userRegisterSchema.safeParse({ + ...validData, + password: "Sicher1234", + passwordConfirm: "Sicher1234", + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("password"); + }); + + it("lehnt Passwort unter 8 Zeichen ab", () => { + const result = userRegisterSchema.safeParse({ + ...validData, + password: "Ku!1", + passwordConfirm: "Ku!1", + }); + expect(result.success).toBe(false); + }); + + it("lehnt nicht übereinstimmende Passwörter ab", () => { + const result = userRegisterSchema.safeParse({ + ...validData, + passwordConfirm: "AndersPasswort!1", + }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("passwordConfirm"); + }); + + it("lehnt Geburtsdatum in der Zukunft ab", () => { + const result = userRegisterSchema.safeParse({ ...validData, birthday: "2099-01-01" }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("birthday"); + }); + + it("lehnt ungültiges Datumsformat ab", () => { + const result = userRegisterSchema.safeParse({ ...validData, birthday: "15.05.1990" }); + expect(result.success).toBe(false); + }); + + it("lehnt fehlendes Geschlecht ab", () => { + const result = userRegisterSchema.safeParse({ ...validData, gender: "" }); + expect(result.success).toBe(false); + expect(result.error?.issues[0].path[0]).toBe("gender"); + }); + + it("lehnt ungültigen Gender-Wert ab", () => { + const result = userRegisterSchema.safeParse({ ...validData, gender: "Unknown" }); + expect(result.success).toBe(false); + }); + + it("akzeptiert alle Gender-Werte", () => { + for (const gender of ["Men", "Woman", "Various"] as const) { + const result = userRegisterSchema.safeParse({ ...validData, gender }); + expect(result.success).toBe(true); + } + }); +}); diff --git a/tests/utils/utils.test.ts b/tests/utils/utils.test.ts new file mode 100644 index 0000000..cfe333c --- /dev/null +++ b/tests/utils/utils.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { cn } from "@/src/lib/utils"; + +describe("cn", () => { + it("gibt eine einzelne Klasse zurück", () => { + expect(cn("px-4")).toBe("px-4"); + }); + + it("kombiniert mehrere Klassen", () => { + expect(cn("px-4", "py-2", "text-sm")).toBe("px-4 py-2 text-sm"); + }); + + it("überschreibt konfliktive Tailwind-Klassen (twMerge)", () => { + expect(cn("px-2", "px-4")).toBe("px-4"); + expect(cn("text-sm", "text-lg")).toBe("text-lg"); + }); + + it("ignoriert falsy-Werte (clsx)", () => { + expect(cn("px-4", false && "text-red", undefined, null as never, "py-2")).toBe("px-4 py-2"); + }); + + it("verarbeitet bedingte Objekte", () => { + expect(cn({ "px-4": true, "py-2": false, "text-sm": true })).toBe("px-4 text-sm"); + }); + + it("verarbeitet Arrays", () => { + expect(cn(["px-4", "py-2"])).toBe("px-4 py-2"); + }); + + it("gibt leeren String bei keinen Argumenten zurück", () => { + expect(cn()).toBe(""); + }); + + it("gibt leeren String bei nur falsy-Werten zurück", () => { + expect(cn(false, undefined, null as never)).toBe(""); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a4b3684..5bbfcd5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,12 +1,29 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; +import path from "path"; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./"), + }, + }, test: { globals: true, environment: "jsdom", setupFiles: "./vitest.setup.ts", - include: ["**/*.test.{ts,tsx}"], + include: ["tests/**/*.test.{ts,tsx}"], + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: [ + "src/app/api/**", + "src/hooks/**", + "src/modules/**/schemas/**", + "src/modules/**/validation/**", + "src/lib/**", + ], + }, }, }); diff --git a/vitest.setup.ts b/vitest.setup.ts index d0de870..a4e2be8 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,6 @@ import "@testing-library/jest-dom"; +import { server } from "./tests/msw/server"; + +beforeAll(() => server.listen({ onUnhandledRequest: "warn" })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close());