diff --git a/.gitignore b/.gitignore index d2b9c5b..5d44f54 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ docs/MODULE_COMPLETION_REPORT.md src/routeTree.gen.ts .reports/ tanstack-start/ + +# Test outputs +coverage/ +test-results/ diff --git a/package.json b/package.json index 7098188..01fce06 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "imports": { "#/*": "./src/*" }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "scripts": { "dev": "vite dev --port 3000", "build": "vite build", @@ -86,16 +89,20 @@ }, "devDependencies": { "@base-ui/react": "^1.4.1", + "@playwright/test": "^1.60.0", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/typography": "^0.5.16", "@tanstack/devtools-vite": "latest", "@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", "@types/lodash.throttle": "^4.1.9", "@types/node": "^22.10.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", "babel-plugin-react-compiler": "^1.0.0", "class-variance-authority": "^0.7.1", "dotenv-cli": "^11.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..e6c9309 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + ], + webServer: { + command: 'pnpm dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}) \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee3011e..cf89c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 7.8.0 '@prisma/client': specifier: ^7.4.2 - version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3) + version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -127,7 +127,7 @@ importers: version: 3.23.4 better-auth: specifier: ^1.5.3 - version: 1.6.11(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(@tanstack/react-start@1.168.9(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)))(mysql2@3.15.3)(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(solid-js@1.9.13)(vitest@4.1.6(@types/node@22.19.19)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1))) + version: 1.6.11(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(@tanstack/react-start@1.168.9(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)))(mysql2@3.15.3)(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(solid-js@1.9.13)(vitest@4.1.6) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -163,7 +163,7 @@ importers: version: 3.0.260522-beta(@electric-sql/pglite@0.4.1)(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.2.0)(jiti@2.7.0)(lru-cache@11.3.6)(mysql2@3.15.3)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) prisma: specifier: ^7.4.2 - version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + version: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -204,6 +204,9 @@ importers: '@base-ui/react': specifier: ^1.4.1 version: 1.4.1(@types/react@19.2.14)(date-fns@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.2)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) @@ -216,9 +219,15 @@ importers: '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/lodash.throttle': specifier: ^4.1.9 version: 4.1.9 @@ -234,6 +243,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.2)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)))(babel-plugin-react-compiler@1.0.0)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) + '@vitest/coverage-v8': + specifier: ^4.1.7 + version: 4.1.7(vitest@4.1.6) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -263,13 +275,16 @@ importers: version: 8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1) vitest: specifier: ^4.1.5 - version: 4.1.6(@types/node@22.19.19)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) + version: 4.1.6(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) packages: '@acemir/cssom@0.9.31': resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -522,6 +537,10 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/core@1.6.11': resolution: {integrity: sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==} peerDependencies: @@ -1160,6 +1179,11 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@prisma/adapter-pg@7.8.0': resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} @@ -2574,6 +2598,10 @@ packages: resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.2': resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} engines: {node: '>=18'} @@ -2589,6 +2617,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.23.4': resolution: {integrity: sha512-ni2LWE52bVeSt3L2HVBSmbBw+elc32ATej9C68EyKzN/8vR5ILxFn6RCdDTKm4asmwZyq2jys12dKmBdWMr9QA==} peerDependencies: @@ -2836,6 +2870,15 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/coverage-v8@4.1.7': + resolution: {integrity: sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==} + peerDependencies: + '@vitest/browser': 4.1.7 + vitest: 4.1.7 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.6': resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} @@ -2853,6 +2896,9 @@ packages: '@vitest/pretty-format@4.1.6': resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/runner@4.1.6': resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} @@ -2865,6 +2911,9 @@ packages: '@vitest/utils@4.1.6': resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -2898,6 +2947,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.2: + resolution: {integrity: sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -3090,6 +3142,9 @@ packages: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3182,6 +3237,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -3339,6 +3397,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3415,6 +3478,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -3443,6 +3509,10 @@ packages: immutable@5.1.5: resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + input-otp@1.4.2: resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} peerDependencies: @@ -3470,6 +3540,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3495,6 +3577,9 @@ packages: react: optional: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3640,6 +3725,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + marked@17.0.6: resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} engines: {node: '>= 20'} @@ -3648,6 +3740,10 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3815,6 +3911,16 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + postal-mime@2.7.4: resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} @@ -4013,6 +4119,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} @@ -4197,6 +4307,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + seq-queue@0.0.5: resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} @@ -4282,9 +4397,17 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -4706,6 +4829,8 @@ snapshots: '@acemir/cssom@0.9.31': {} + '@adobe/css-tools@4.5.0': {} + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -5135,6 +5260,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.0 @@ -5169,13 +5296,13 @@ snapshots: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 - '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))': + '@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))': dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/utils': 0.4.0 optionalDependencies: - '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3) - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3) + prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) '@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)': dependencies: @@ -5565,6 +5692,10 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@prisma/adapter-pg@7.8.0': dependencies: '@prisma/driver-adapter-utils': 7.8.0 @@ -5576,16 +5707,16 @@ snapshots: '@prisma/client-runtime-utils@7.8.0': {} - '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3)': + '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3)': dependencies: '@prisma/client-runtime-utils': 7.8.0 optionalDependencies: - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) typescript: 6.0.3 - '@prisma/config@7.8.0': + '@prisma/config@7.8.0(magicast@0.5.3)': dependencies: - c12: 3.3.4 + c12: 3.3.4(magicast@0.5.3) deepmerge-ts: 7.1.5 effect: 3.20.0 empathic: 2.0.0 @@ -7050,6 +7181,15 @@ snapshots: picocolors: 1.1.1 pretty-format: 27.5.1 + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@babel/runtime': 7.29.2 @@ -7060,6 +7200,10 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tiptap/core@3.23.4(@tiptap/pm@3.23.4)': dependencies: '@tiptap/pm': 3.23.4 @@ -7321,6 +7465,20 @@ snapshots: '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.2)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) babel-plugin-react-compiler: 1.0.0 + '@vitest/coverage-v8@4.1.7(vitest@4.1.6)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.7 + ast-v8-to-istanbul: 1.0.2 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 @@ -7342,6 +7500,10 @@ snapshots: dependencies: tinyrainbow: 3.1.0 + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@4.1.6': dependencies: '@vitest/utils': 4.1.6 @@ -7362,6 +7524,12 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + agent-base@7.1.4: {} ajv@8.20.0: @@ -7389,6 +7557,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.2: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + aws-ssl-profiles@1.1.2: {} babel-dead-code-elimination@1.0.12: @@ -7406,14 +7580,14 @@ snapshots: baseline-browser-mapping@2.10.31: {} - better-auth@1.6.11(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(@tanstack/react-start@1.168.9(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)))(mysql2@3.15.3)(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(solid-js@1.9.13)(vitest@4.1.6(@types/node@22.19.19)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1))): + better-auth@1.6.11(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(@tanstack/react-start@1.168.9(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)))(mysql2@3.15.3)(pg@8.20.0)(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(solid-js@1.9.13)(vitest@4.1.6): dependencies: '@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) '@better-auth/kysely-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) '@better-auth/memory-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) '@better-auth/mongo-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) - '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)) + '@better-auth/prisma-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3)) '@better-auth/telemetry': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) '@better-auth/utils': 0.4.0 '@better-fetch/fetch': 1.1.21 @@ -7426,15 +7600,15 @@ snapshots: nanostores: 1.3.0 zod: 4.4.3 optionalDependencies: - '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3) + '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3))(typescript@6.0.3) '@tanstack/react-start': 1.168.9(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) mysql2: 3.15.3 pg: 8.20.0 - prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) + prisma: 7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) solid-js: 1.9.13 - vitest: 4.1.6(@types/node@22.19.19)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) + vitest: 4.1.6(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -7466,7 +7640,7 @@ snapshots: node-releases: 2.0.44 update-browserslist-db: 1.2.3(browserslist@4.28.2) - c12@3.3.4: + c12@3.3.4(magicast@0.5.3): dependencies: chokidar: 5.0.0 confbox: 0.2.4 @@ -7480,6 +7654,8 @@ snapshots: perfect-debounce: 2.1.0 pkg-types: 2.3.1 rc9: 3.0.1 + optionalDependencies: + magicast: 0.5.3 caniuse-lite@1.0.30001793: {} @@ -7576,6 +7752,8 @@ snapshots: css-what@6.2.2: {} + css.escape@1.5.1: {} + cssesc@3.0.0: {} cssstyle@6.2.0: @@ -7631,6 +7809,8 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -7785,6 +7965,9 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7813,7 +7996,7 @@ snapshots: h3@2.0.1-rc.20(crossws@0.4.5(srvx@0.11.16)): dependencies: rou3: 0.8.1 - srvx: 0.11.15 + srvx: 0.11.16 optionalDependencies: crossws: 0.4.5(srvx@0.11.15) @@ -7838,6 +8021,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-escaper@2.0.2: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -7873,6 +8058,8 @@ snapshots: immutable@5.1.5: {} + indent-string@4.0.0: {} + input-otp@1.4.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 @@ -7894,6 +8081,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@2.7.0: {} jose@6.2.3: {} @@ -7905,6 +8105,8 @@ snapshots: '@types/react': 19.2.14 react: 19.2.6 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -8030,10 +8232,22 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.1 + marked@17.0.6: {} mdn-data@2.27.1: {} + min-indent@1.0.1: {} + minimist@1.2.8: {} motion-dom@12.38.0: @@ -8242,6 +8456,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + postal-mime@2.7.4: {} postcss-selector-parser@6.0.10: @@ -8277,9 +8499,9 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3): + prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(magicast@0.5.3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@6.0.3): dependencies: - '@prisma/config': 7.8.0 + '@prisma/config': 7.8.0(magicast@0.5.3) '@prisma/dev': 0.24.3(typescript@6.0.3) '@prisma/engines': 7.8.0 '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -8510,6 +8732,11 @@ snapshots: readdirp@5.0.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + remeda@2.33.4: {} remove-accents@0.5.0: {} @@ -8683,6 +8910,8 @@ snapshots: semver@6.3.1: {} + semver@7.8.1: {} + seq-queue@0.0.5: {} seroval-plugins@1.5.4(seroval@1.5.4): @@ -8741,8 +8970,16 @@ snapshots: std-env@4.1.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strnum@2.3.0: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -8901,7 +9138,7 @@ snapshots: optionalDependencies: vite: 8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1) - vitest@4.1.6(@types/node@22.19.19)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)): + vitest@4.1.6(@types/node@22.19.19)(@vitest/coverage-v8@4.1.7)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)): dependencies: '@vitest/expect': 4.1.6 '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(sass-embedded@1.99.0)(sass@1.99.0)(tsx@4.22.1)) @@ -8925,6 +9162,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.19.19 + '@vitest/coverage-v8': 4.1.7(vitest@4.1.6) jsdom: 28.1.0(@noble/hashes@2.2.0) transitivePeerDependencies: - msw diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..158f84c --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,150 @@ +import { PrismaClient } from '../src/generated/prisma/client.js' +import { PrismaPg } from '@prisma/adapter-pg' + +// Simple ID generator +function generateId(): string { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) +} + +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL!, +}) + +const prisma = new PrismaClient({ adapter }) + +// Test user credentials (consistent for E2E tests) +export const TEST_USER = { + email: 'test-user@cedium.test', + password: 'TestPassword123!', + name: 'Test User', +} + +export const TEST_AUTHOR = { + email: 'test-author@cedium.test', + password: 'AuthorPassword123!', + name: 'Test Author', +} + +async function main() { + console.log('🌱 Seeding test data...') + + // Delete test users (cascade will clean all related data) + try { + await prisma.user.deleteMany({ + where: { + email: { in: [TEST_USER.email, TEST_AUTHOR.email] } + } + }) + console.log(' Cleaned existing test data') + } catch { + // Ignore if users don't exist + } + + // Create test users + const testUser = await prisma.user.create({ + data: { + id: generateId(), + email: TEST_USER.email, + name: TEST_USER.name, + emailVerified: true, + accounts: { + create: { + id: generateId(), + accountId: TEST_USER.email, + providerId: 'credential', + password: TEST_USER.password, + } + } + } + }) + console.log(` Created user: ${TEST_USER.email}`) + + const testAuthor = await prisma.user.create({ + data: { + id: generateId(), + email: TEST_AUTHOR.email, + name: TEST_AUTHOR.name, + emailVerified: true, + bio: 'Test author for E2E testing', + accounts: { + create: { + id: generateId(), + accountId: TEST_AUTHOR.email, + providerId: 'credential', + password: TEST_AUTHOR.password, + } + } + } + }) + console.log(` Created user: ${TEST_AUTHOR.email}`) + + // Create tags + const techTag = await prisma.tag.create({ + data: { name: '技术', slug: 'tech-e2e' } + }) + const lifeTag = await prisma.tag.create({ + data: { name: '生活', slug: 'life-e2e' } + }) + + console.log(' Created tags: 技术, 生活') + + // Create published articles + const article1 = await prisma.article.create({ + data: { + title: '测试文章 - E2E Testing', + slug: 'test-article-e2e-testing', + excerpt: '这是一篇用于 E2E 测试的文章', + content: '# 测试文章\n\n这是测试内容。\n\n## 功能\n\n- 点赞\n- 收藏\n- 评论', + status: 'PUBLISHED', + publishedAt: new Date(), + authorId: testAuthor.id, + likeCount: 5, + bookmarkCount: 2, + commentCount: 1, + } + }) + await prisma.articleTag.create({ + data: { articleId: article1.id, tagId: techTag.id } + }) + + // Create second article with life tag + const article2 = await prisma.article.create({ + data: { + title: '生活随笔 - Life Notes', + slug: 'test-article-life-notes', + excerpt: '这是一篇关于生活的测试文章', + content: '# 生活随笔\n\n记录生活的点滴。\n\n## 主题\n\n- 日常\n- 思考\n- 感悟', + status: 'PUBLISHED', + publishedAt: new Date(), + authorId: testAuthor.id, + likeCount: 3, + bookmarkCount: 1, + commentCount: 0, + } + }) + await prisma.articleTag.create({ + data: { articleId: article2.id, tagId: lifeTag.id } + }) + console.log(' Created 2 test articles') + + // Create comment + await prisma.comment.create({ + data: { + content: '这是一条测试评论', + articleId: article1.id, + userId: testUser.id, + likeCount: 2, + } + }) + + console.log('✅ Test data seeded!') +} + +main() + .catch((e) => { + console.error('❌ Seed failed:', e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) \ No newline at end of file diff --git a/src/hooks/use-debounce.test.ts b/src/hooks/use-debounce.test.ts new file mode 100644 index 0000000..b8573cc --- /dev/null +++ b/src/hooks/use-debounce.test.ts @@ -0,0 +1,105 @@ +import { describe, test, expect, vi, afterEach } from 'vitest' +import { renderHook, act } from '@/test/utils/test-utils' +import { useDebounce } from './use-debounce' + +describe('useDebounce', () => { + afterEach(() => { + vi.useRealTimers() + }) + + test('returns initial value immediately', () => { + const { result } = renderHook(() => useDebounce('test', 500)) + expect(result.current).toBe('test') + }) + + test('debounces value changes', () => { + vi.useFakeTimers() + + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'initial', delay: 500 } } + ) + + expect(result.current).toBe('initial') + + rerender({ value: 'changed', delay: 500 }) + expect(result.current).toBe('initial') + + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(result.current).toBe('changed') + }) + + test('cancels previous timeout on rapid changes', () => { + vi.useFakeTimers() + + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 500), + { initialProps: { value: 'a' } } + ) + + rerender({ value: 'b' }) + act(() => { + vi.advanceTimersByTime(250) + }) + + rerender({ value: 'c' }) + act(() => { + vi.advanceTimersByTime(500) + }) + + expect(result.current).toBe('c') + }) + + test('updates delay dynamically', () => { + vi.useFakeTimers() + + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { initialProps: { value: 'test', delay: 500 } } + ) + + rerender({ value: 'changed', delay: 100 }) + act(() => { + vi.advanceTimersByTime(100) + }) + + expect(result.current).toBe('changed') + }) + + test('works with numbers', () => { + vi.useFakeTimers() + + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 300), + { initialProps: { value: 0 } } + ) + + expect(result.current).toBe(0) + + rerender({ value: 42 }) + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(result.current).toBe(42) + }) + + test('works with objects', () => { + vi.useFakeTimers() + + const { result, rerender } = renderHook( + ({ value }) => useDebounce(value, 300), + { initialProps: { value: { name: 'initial' } } } + ) + + rerender({ value: { name: 'updated' } }) + act(() => { + vi.advanceTimersByTime(300) + }) + + expect(result.current.name).toBe('updated') + }) +}) \ No newline at end of file diff --git a/src/hooks/use-is-breakpoint.test.ts b/src/hooks/use-is-breakpoint.test.ts new file mode 100644 index 0000000..13b011d --- /dev/null +++ b/src/hooks/use-is-breakpoint.test.ts @@ -0,0 +1,82 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { renderHook } from '@/test/utils/test-utils' +import { useIsBreakpoint } from './use-is-breakpoint' + +describe('useIsBreakpoint', () => { + beforeEach(() => { + // Mock matchMedia + const matchMediaMock = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('min-width'), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: matchMediaMock, + }) + }) + + test('returns false for max mode with width above breakpoint', () => { + const matchMediaMock = vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + window.matchMedia = matchMediaMock + + const { result } = renderHook(() => useIsBreakpoint('max', 768)) + expect(result.current).toBe(false) + }) + + test('returns true for max mode with width below breakpoint', () => { + const matchMediaMock = vi.fn().mockImplementation((query: string) => ({ + matches: true, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + window.matchMedia = matchMediaMock + + const { result } = renderHook(() => useIsBreakpoint('max', 768)) + expect(result.current).toBe(true) + }) + + test('returns true for min mode with width above breakpoint', () => { + const matchMediaMock = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('min-width'), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + window.matchMedia = matchMediaMock + + const { result } = renderHook(() => useIsBreakpoint('min', 1024)) + expect(result.current).toBe(true) + }) + + test('uses default values when not provided', () => { + const { result } = renderHook(() => useIsBreakpoint()) + // Default mode='max', breakpoint=768, mock returns false for max-width queries + expect(result.current).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/hooks/use-mobile.test.ts b/src/hooks/use-mobile.test.ts new file mode 100644 index 0000000..fc6c128 --- /dev/null +++ b/src/hooks/use-mobile.test.ts @@ -0,0 +1,56 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { renderHook } from '@/test/utils/test-utils' +import { useIsMobile } from './use-mobile' + +describe('useIsMobile', () => { + beforeEach(() => { + // Mock window.innerWidth + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1024, + }) + + // Mock matchMedia + const matchMediaMock = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('767'), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: matchMediaMock, + }) + }) + + test('returns false for desktop width', () => { + window.innerWidth = 1024 + const { result } = renderHook(() => useIsMobile()) + expect(result.current).toBe(false) + }) + + test('returns true for mobile width', async () => { + window.innerWidth = 375 + const matchMediaMock = vi.fn().mockImplementation((query: string) => ({ + matches: true, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + window.matchMedia = matchMediaMock + + const { result } = renderHook(() => useIsMobile()) + expect(result.current).toBe(true) + }) +}) \ No newline at end of file diff --git a/src/lib/utils/slug.test.ts b/src/lib/utils/slug.test.ts new file mode 100644 index 0000000..2166a5a --- /dev/null +++ b/src/lib/utils/slug.test.ts @@ -0,0 +1,44 @@ +import { describe, test, expect } from 'vitest' +import { generateSlug } from './slug' + +describe('generateSlug', () => { + test('converts to lowercase', () => { + expect(generateSlug('Hello World')).toBe('hello-world') + }) + + test('replaces spaces with hyphens', () => { + expect(generateSlug('test multiple spaces')).toBe('test-multiple-spaces') + }) + + test('removes special characters', () => { + expect(generateSlug('test!@#$%^&*()')).toBe('test') + }) + + test('preserves Chinese characters', () => { + expect(generateSlug('测试文章')).toBe('测试文章') + }) + + test('handles mixed Chinese and English', () => { + expect(generateSlug('Test 测试 Article')).toBe('test-测试-article') + }) + + test('trims whitespace', () => { + expect(generateSlug(' test ')).toBe('test') + }) + + test('returns empty for empty input', () => { + expect(generateSlug('')).toBe('') + }) + + test('handles numbers', () => { + expect(generateSlug('Article 123')).toBe('article-123') + }) + + test('handles underscores', () => { + expect(generateSlug('test_article_name')).toBe('test_article_name') + }) + + test('collapses consecutive hyphens', () => { + expect(generateSlug('test---article')).toBe('test-article') + }) +}) \ No newline at end of file diff --git a/src/lib/utils/slug.ts b/src/lib/utils/slug.ts index 0e1d743..31adcc7 100644 --- a/src/lib/utils/slug.ts +++ b/src/lib/utils/slug.ts @@ -11,4 +11,5 @@ export function generateSlug(name: string): string { .trim() .replace(/\s+/g, '-') .replace(/[^\w\-一-龥]/g, '') + .replace(/-+/g, '-') } \ No newline at end of file diff --git a/src/lib/utils/tag.test.ts b/src/lib/utils/tag.test.ts new file mode 100644 index 0000000..124ef36 --- /dev/null +++ b/src/lib/utils/tag.test.ts @@ -0,0 +1,57 @@ +import { describe, test, expect } from 'vitest' +import { searchTags } from './tag' +import type { Tag } from '#/types/tag' + +const mockTags: Tag[] = [ + { id: '1', name: 'React', slug: 'react' }, + { id: '2', name: 'TypeScript', slug: 'typescript' }, + { id: '3', name: '测试', slug: 'test' }, +] + +describe('searchTags', () => { + test('returns all tags when query is empty', () => { + expect(searchTags(mockTags, '')).toEqual(mockTags) + }) + + test('returns all tags when query is whitespace', () => { + expect(searchTags(mockTags, ' ')).toEqual(mockTags) + }) + + test('returns empty array when tags is undefined', () => { + expect(searchTags(undefined, 'react')).toEqual([]) + }) + + test('filters by name', () => { + const result = searchTags(mockTags, 'react') + expect(result).toHaveLength(1) + expect(result[0].name).toBe('React') + }) + + test('filters by slug', () => { + const result = searchTags(mockTags, 'typescript') + expect(result).toHaveLength(1) + expect(result[0].slug).toBe('typescript') + }) + + test('filters Chinese tags', () => { + const result = searchTags(mockTags, '测试') + expect(result).toHaveLength(1) + expect(result[0].name).toBe('测试') + }) + + test('case-insensitive search', () => { + const result = searchTags(mockTags, 'REACT') + expect(result).toHaveLength(1) + expect(result[0].name).toBe('React') + }) + + test('partial match', () => { + const result = searchTags(mockTags, 'type') + expect(result).toHaveLength(1) + expect(result[0].name).toBe('TypeScript') + }) + + test('returns empty array when no match', () => { + expect(searchTags(mockTags, 'nonexistent')).toEqual([]) + }) +}) \ No newline at end of file diff --git a/src/lib/utils/time.test.ts b/src/lib/utils/time.test.ts new file mode 100644 index 0000000..f08b446 --- /dev/null +++ b/src/lib/utils/time.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect } from 'vitest' +import { timeAgo } from './time' + +describe('timeAgo', () => { + test('returns "今天" for today', () => { + const now = new Date() + expect(timeAgo(now)).toBe('今天') + }) + + test('returns "昨天" for yesterday', () => { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + expect(timeAgo(yesterday)).toBe('昨天') + }) + + test('returns days ago for days within a week', () => { + const threeDaysAgo = new Date() + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3) + expect(timeAgo(threeDaysAgo)).toBe('3 天前') + }) + + test('returns weeks ago for days within a month', () => { + const twoWeeksAgo = new Date() + twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14) + expect(timeAgo(twoWeeksAgo)).toBe('2 周前') + }) + + test('returns months ago for days within a year', () => { + const twoMonthsAgo = new Date() + twoMonthsAgo.setDate(twoMonthsAgo.getDate() - 60) + expect(timeAgo(twoMonthsAgo)).toBe('2 个月前') + }) + + test('returns years ago for days over a year', () => { + const twoYearsAgo = new Date() + twoYearsAgo.setFullYear(twoYearsAgo.getFullYear() - 2) + expect(timeAgo(twoYearsAgo)).toBe('2 年前') + }) + + test('accepts string date', () => { + const dateString = new Date().toISOString() + expect(timeAgo(dateString)).toBe('今天') + }) + + test('returns future relative time for future dates', () => { + const tomorrow = new Date() + tomorrow.setDate(tomorrow.getDate() + 1) + expect(timeAgo(tomorrow)).toBe('明天') + + const threeDaysLater = new Date() + threeDaysLater.setDate(threeDaysLater.getDate() + 3) + expect(timeAgo(threeDaysLater)).toBe('3 天后') + + const twoWeeksLater = new Date() + twoWeeksLater.setDate(twoWeeksLater.getDate() + 14) + expect(timeAgo(twoWeeksLater)).toBe('2 周后') + }) +}) \ No newline at end of file diff --git a/src/lib/utils/time.ts b/src/lib/utils/time.ts index 68cca90..99da6a4 100644 --- a/src/lib/utils/time.ts +++ b/src/lib/utils/time.ts @@ -9,6 +9,16 @@ export function timeAgo(date: Date | string): string { const diffMs = now.getTime() - then.getTime() const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + // Handle future dates with proper semantics + if (diffDays < 0) { + const futureDays = Math.abs(diffDays) + if (futureDays === 0) return "今天" + if (futureDays === 1) return "明天" + if (futureDays < 7) return `${futureDays} 天后` + if (futureDays < 30) return `${Math.floor(futureDays / 7)} 周后` + if (futureDays < 365) return `${Math.floor(futureDays / 30)} 个月后` + return `${Math.floor(futureDays / 365)} 年后` + } if (diffDays === 0) return "今天" if (diffDays === 1) return "昨天" if (diffDays < 7) return `${diffDays} 天前` diff --git a/src/lib/validators/article.test.ts b/src/lib/validators/article.test.ts new file mode 100644 index 0000000..355767d --- /dev/null +++ b/src/lib/validators/article.test.ts @@ -0,0 +1,323 @@ +import { describe, test, expect } from 'vitest' +import { + createArticleSchema, + updateArticleSchema, + publishArticleSchema, + archiveArticleSchema, + unpublishArticleSchema, + restoreArticleSchema, + getArticleByIdSchema, + getMyArticlesSchema, + searchArticlesSchema, + getArticlesByAuthorSchema, + deleteArticleSchema, + paginationSchema, + cursorPaginationSchema, +} from './article' + +describe('createArticleSchema', () => { + test('accepts valid article data', () => { + const result = createArticleSchema.safeParse({ + title: 'Test Article', + excerpt: 'Test excerpt', + content: 'Test content', + coverImage: 'https://example.com/image.jpg', + tags: ['tag1', 'tag2'], + }) + expect(result.success).toBe(true) + }) + + test('accepts article without optional fields', () => { + const result = createArticleSchema.safeParse({ + title: 'Test Article', + content: 'Test content', + }) + expect(result.success).toBe(true) + }) + + test('rejects empty title', () => { + const result = createArticleSchema.safeParse({ + title: '', + content: 'Test content', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('标题不能为空') + } + }) + + test('rejects title over 100 characters', () => { + const result = createArticleSchema.safeParse({ + title: 'a'.repeat(101), + content: 'Test content', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('标题不能超过100个字符') + } + }) + + test('rejects excerpt over 200 characters', () => { + const result = createArticleSchema.safeParse({ + title: 'Test Article', + excerpt: 'a'.repeat(201), + content: 'Test content', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('摘要不能超过200个字符') + } + }) + + test('rejects invalid coverImage URL', () => { + const result = createArticleSchema.safeParse({ + title: 'Test Article', + content: 'Test content', + coverImage: 'invalid-url', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('请输入有效的图片URL') + } + }) + + test('rejects more than 5 tags', () => { + const result = createArticleSchema.safeParse({ + title: 'Test Article', + content: 'Test content', + tags: ['t1', 't2', 't3', 't4', 't5', 't6'], + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('最多添加5个标签') + } + }) +}) + +describe('updateArticleSchema', () => { + test('accepts valid update data', () => { + const result = updateArticleSchema.safeParse({ + id: 'article-id', + title: 'Updated Title', + content: 'Updated content', + }) + expect(result.success).toBe(true) + }) + + test('rejects empty id', () => { + const result = updateArticleSchema.safeParse({ + id: '', + title: 'Updated Title', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID不能为空') + } + }) + + test('rejects empty content if provided', () => { + const result = updateArticleSchema.safeParse({ + id: 'article-id', + content: '', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('内容不能为空') + } + }) + + test('rejects title over 100 characters', () => { + const result = updateArticleSchema.safeParse({ + id: 'article-id', + title: 'a'.repeat(101), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('标题不能超过100个字符') + } + }) + + test('rejects excerpt over 200 characters', () => { + const result = updateArticleSchema.safeParse({ + id: 'article-id', + excerpt: 'a'.repeat(201), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('摘要不能超过200个字符') + } + }) +}) + +describe('publishArticleSchema', () => { + test('accepts valid id', () => { + const result = publishArticleSchema.safeParse({ id: 'article-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty id', () => { + const result = publishArticleSchema.safeParse({ id: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID不能为空') + } + }) +}) + +describe('archiveArticleSchema', () => { + test('accepts valid id', () => { + const result = archiveArticleSchema.safeParse({ id: 'article-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty id', () => { + const result = archiveArticleSchema.safeParse({ id: '' }) + expect(result.success).toBe(false) + }) +}) + +describe('unpublishArticleSchema', () => { + test('accepts valid id', () => { + const result = unpublishArticleSchema.safeParse({ id: 'article-id' }) + expect(result.success).toBe(true) + }) +}) + +describe('restoreArticleSchema', () => { + test('accepts valid id', () => { + const result = restoreArticleSchema.safeParse({ id: 'article-id' }) + expect(result.success).toBe(true) + }) +}) + +describe('getArticleByIdSchema', () => { + test('accepts valid id', () => { + const result = getArticleByIdSchema.safeParse({ id: 'article-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty id', () => { + const result = getArticleByIdSchema.safeParse({ id: '' }) + expect(result.success).toBe(false) + }) +}) + +describe('paginationSchema', () => { + test('applies defaults', () => { + const result = paginationSchema.safeParse({}) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.page).toBe(1) + expect(result.data.limit).toBe(10) + } + }) + + test('accepts valid pagination', () => { + const result = paginationSchema.safeParse({ page: 2, limit: 20 }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.page).toBe(2) + expect(result.data.limit).toBe(20) + } + }) + + test('rejects limit over 50', () => { + const result = paginationSchema.safeParse({ page: 1, limit: 51 }) + expect(result.success).toBe(false) + }) + + test('rejects page less than 1', () => { + const result = paginationSchema.safeParse({ page: 0, limit: 10 }) + expect(result.success).toBe(false) + }) +}) + +describe('cursorPaginationSchema', () => { + test('applies default limit', () => { + const result = cursorPaginationSchema.safeParse({}) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.limit).toBe(10) + } + }) + + test('accepts valid cursor', () => { + const result = cursorPaginationSchema.safeParse({ cursor: 'abc123', limit: 15 }) + expect(result.success).toBe(true) + }) + + test('rejects limit over 20', () => { + const result = cursorPaginationSchema.safeParse({ limit: 21 }) + expect(result.success).toBe(false) + }) +}) + +describe('searchArticlesSchema', () => { + test('accepts valid search query', () => { + const result = searchArticlesSchema.safeParse({ query: 'test' }) + expect(result.success).toBe(true) + }) + + test('rejects empty query', () => { + const result = searchArticlesSchema.safeParse({ query: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('搜索关键词不能为空') + } + }) + + test('rejects query over 100 characters', () => { + const result = searchArticlesSchema.safeParse({ query: 'a'.repeat(101) }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('搜索关键词不能超过100个字符') + } + }) +}) + +describe('getArticlesByAuthorSchema', () => { + test('accepts username', () => { + const result = getArticlesByAuthorSchema.safeParse({ username: 'testuser' }) + expect(result.success).toBe(true) + }) + + test('accepts authorId', () => { + const result = getArticlesByAuthorSchema.safeParse({ authorId: 'author-id' }) + expect(result.success).toBe(true) + }) + + test('rejects missing both username and authorId', () => { + const result = getArticlesByAuthorSchema.safeParse({}) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('请提供用户名或用户ID') + } + }) +}) + +describe('deleteArticleSchema', () => { + test('accepts valid id', () => { + const result = deleteArticleSchema.safeParse({ id: 'article-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty id', () => { + const result = deleteArticleSchema.safeParse({ id: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID不能为空') + } + }) +}) + +describe('getMyArticlesSchema', () => { + test('accepts valid input with status', () => { + const result = getMyArticlesSchema.safeParse({ status: 'DRAFT' }) + expect(result.success).toBe(true) + }) + + test('accepts valid input without status', () => { + const result = getMyArticlesSchema.safeParse({}) + expect(result.success).toBe(true) + }) +}) \ No newline at end of file diff --git a/src/lib/validators/auth.test.ts b/src/lib/validators/auth.test.ts new file mode 100644 index 0000000..08f17a9 --- /dev/null +++ b/src/lib/validators/auth.test.ts @@ -0,0 +1,212 @@ +import { describe, test, expect } from 'vitest' +import { + loginSchema, + signupSchema, + forgotPasswordSchema, + resetPasswordSchema, + changePasswordSchema, + newPasswordSchema, +} from './auth' + +describe('loginSchema', () => { + test('accepts valid email and password', () => { + const result = loginSchema.safeParse({ + email: 'test@example.com', + password: 'password123', + }) + expect(result.success).toBe(true) + }) + + test('rejects invalid email', () => { + const result = loginSchema.safeParse({ + email: 'invalid-email', + password: 'password123', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('请输入有效的邮箱地址') + } + }) + + test('rejects short password', () => { + const result = loginSchema.safeParse({ + email: 'test@example.com', + password: 'short', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('密码至少需要8个字符') + } + }) +}) + +describe('signupSchema', () => { + test('accepts valid signup data', () => { + const result = signupSchema.safeParse({ + name: 'Test User', + email: 'test@example.com', + password: 'password123', + confirmPassword: 'password123', + }) + expect(result.success).toBe(true) + }) + + test('rejects mismatched passwords', () => { + const result = signupSchema.safeParse({ + name: 'Test User', + email: 'test@example.com', + password: 'password123', + confirmPassword: 'different', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(e => e.message === '两次输入的密码不一致')).toBe(true) + } + }) + + test('rejects short name', () => { + const result = signupSchema.safeParse({ + name: 'T', + email: 'test@example.com', + password: 'password123', + confirmPassword: 'password123', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('姓名至少需要2个字符') + } + }) + + test('rejects password over 128 characters', () => { + const result = signupSchema.safeParse({ + name: 'Test User', + email: 'test@example.com', + password: 'a'.repeat(129), + confirmPassword: 'a'.repeat(129), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(e => e.message === '密码不能超过128个字符')).toBe(true) + } + }) +}) + +describe('forgotPasswordSchema', () => { + test('accepts valid email', () => { + const result = forgotPasswordSchema.safeParse({ + email: 'test@example.com', + }) + expect(result.success).toBe(true) + }) + + test('rejects invalid email', () => { + const result = forgotPasswordSchema.safeParse({ + email: 'invalid', + }) + expect(result.success).toBe(false) + }) +}) + +describe('resetPasswordSchema', () => { + test('accepts valid passwords', () => { + const result = resetPasswordSchema.safeParse({ + password: 'newpassword123', + confirmPassword: 'newpassword123', + }) + expect(result.success).toBe(true) + }) + + test('rejects mismatched passwords', () => { + const result = resetPasswordSchema.safeParse({ + password: 'newpassword123', + confirmPassword: 'different', + }) + expect(result.success).toBe(false) + }) + + test('rejects password over 128 characters', () => { + const result = resetPasswordSchema.safeParse({ + password: 'a'.repeat(129), + confirmPassword: 'a'.repeat(129), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(e => e.message === '密码不能超过128个字符')).toBe(true) + } + }) +}) + +describe('changePasswordSchema', () => { + test('accepts valid password change', () => { + const result = changePasswordSchema.safeParse({ + currentPassword: 'oldpassword123', + newPassword: 'newpassword123', + confirmPassword: 'newpassword123', + }) + expect(result.success).toBe(true) + }) + + test('rejects mismatched new passwords', () => { + const result = changePasswordSchema.safeParse({ + currentPassword: 'oldpassword123', + newPassword: 'newpassword123', + confirmPassword: 'different', + }) + expect(result.success).toBe(false) + }) + + test('rejects newPassword over 128 characters', () => { + const result = changePasswordSchema.safeParse({ + currentPassword: 'oldpassword123', + newPassword: 'a'.repeat(129), + confirmPassword: 'a'.repeat(129), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(e => e.message === '密码不能超过128个字符')).toBe(true) + } + }) +}) + +describe('newPasswordSchema', () => { + test('accepts valid passwords', () => { + const result = newPasswordSchema.safeParse({ + newPassword: 'newpassword123', + confirmPassword: 'newpassword123', + }) + expect(result.success).toBe(true) + }) + + test('rejects mismatched passwords', () => { + const result = newPasswordSchema.safeParse({ + newPassword: 'newpassword123', + confirmPassword: 'different', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(e => e.message === '两次输入的密码不一致')).toBe(true) + } + }) + + test('rejects short password', () => { + const result = newPasswordSchema.safeParse({ + newPassword: 'short', + confirmPassword: 'short', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(e => e.message === '密码至少需要8个字符')).toBe(true) + } + }) + + test('rejects password over 128 characters', () => { + const result = newPasswordSchema.safeParse({ + newPassword: 'a'.repeat(129), + confirmPassword: 'a'.repeat(129), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(e => e.message === '密码不能超过128个字符')).toBe(true) + } + }) +}) \ No newline at end of file diff --git a/src/lib/validators/bookmark.test.ts b/src/lib/validators/bookmark.test.ts new file mode 100644 index 0000000..fde88f4 --- /dev/null +++ b/src/lib/validators/bookmark.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect } from 'vitest' +import { + bookmarkArticleSchema, + checkBookmarkStatusSchema, + checkMultipleBookmarkStatusSchema, +} from './bookmark' + +describe('bookmarkArticleSchema', () => { + test('accepts valid articleId', () => { + const result = bookmarkArticleSchema.safeParse({ articleId: 'article-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty articleId', () => { + const result = bookmarkArticleSchema.safeParse({ articleId: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID不能为空') + } + }) + + test('rejects articleId over 50 characters', () => { + const result = bookmarkArticleSchema.safeParse({ articleId: 'a'.repeat(51) }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID格式无效') + } + }) +}) + +describe('checkBookmarkStatusSchema', () => { + test('accepts valid articleId', () => { + const result = checkBookmarkStatusSchema.safeParse({ articleId: 'article-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty articleId', () => { + const result = checkBookmarkStatusSchema.safeParse({ articleId: '' }) + expect(result.success).toBe(false) + }) + + test('rejects articleId over 50 characters', () => { + const result = checkBookmarkStatusSchema.safeParse({ articleId: 'a'.repeat(51) }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID格式无效') + } + }) +}) + +describe('checkMultipleBookmarkStatusSchema', () => { + test('accepts valid articleIds array', () => { + const result = checkMultipleBookmarkStatusSchema.safeParse({ + articleIds: ['id1', 'id2', 'id3'], + }) + expect(result.success).toBe(true) + }) + + test('rejects empty array', () => { + const result = checkMultipleBookmarkStatusSchema.safeParse({ + articleIds: [], + }) + expect(result.success).toBe(false) + }) + + test('rejects array over 100 items', () => { + const result = checkMultipleBookmarkStatusSchema.safeParse({ + articleIds: Array(101).fill('id'), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('最多查询100篇文章状态') + } + }) +}) \ No newline at end of file diff --git a/src/lib/validators/comment.test.ts b/src/lib/validators/comment.test.ts new file mode 100644 index 0000000..6be0fb6 --- /dev/null +++ b/src/lib/validators/comment.test.ts @@ -0,0 +1,193 @@ +import { describe, test, expect } from 'vitest' +import { + createCommentSchema, + updateCommentSchema, + deleteCommentSchema, + getCommentsSchema, + likeCommentSchema, + checkCommentLikeStatusSchema, + checkMultipleCommentLikeStatusSchema, +} from './comment' + +describe('createCommentSchema', () => { + test('accepts valid comment', () => { + const result = createCommentSchema.safeParse({ + articleId: 'article-id', + content: 'Test comment content', + }) + expect(result.success).toBe(true) + }) + + test('accepts comment with parentId', () => { + const result = createCommentSchema.safeParse({ + articleId: 'article-id', + content: 'Reply content', + parentId: 'parent-comment-id', + }) + expect(result.success).toBe(true) + }) + + test('rejects empty content', () => { + const result = createCommentSchema.safeParse({ + articleId: 'article-id', + content: '', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('评论内容不能为空') + } + }) + + test('rejects content over 1000 characters', () => { + const result = createCommentSchema.safeParse({ + articleId: 'article-id', + content: 'a'.repeat(1001), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('评论内容不能超过1000个字符') + } + }) + + test('rejects empty articleId', () => { + const result = createCommentSchema.safeParse({ + articleId: '', + content: 'Test comment', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID不能为空') + } + }) +}) + +describe('updateCommentSchema', () => { + test('accepts valid update', () => { + const result = updateCommentSchema.safeParse({ + id: 'comment-id', + content: 'Updated content', + }) + expect(result.success).toBe(true) + }) + + test('rejects empty id', () => { + const result = updateCommentSchema.safeParse({ + id: '', + content: 'Updated content', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('评论ID不能为空') + } + }) +}) + +describe('deleteCommentSchema', () => { + test('accepts valid delete', () => { + const result = deleteCommentSchema.safeParse({ + id: 'comment-id', + articleId: 'article-id', + }) + expect(result.success).toBe(true) + }) + + test('rejects empty id', () => { + const result = deleteCommentSchema.safeParse({ + id: '', + articleId: 'article-id', + }) + expect(result.success).toBe(false) + }) +}) + +describe('getCommentsSchema', () => { + test('applies defaults', () => { + const result = getCommentsSchema.safeParse({ articleId: 'article-id' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.page).toBe(1) + expect(result.data.limit).toBe(10) + expect(result.data.sort).toBe('oldest') + } + }) + + test('accepts valid sort options', () => { + const result = getCommentsSchema.safeParse({ + articleId: 'article-id', + sort: 'newest', + }) + expect(result.success).toBe(true) + }) +}) + +describe('likeCommentSchema', () => { + test('accepts valid commentId', () => { + const result = likeCommentSchema.safeParse({ commentId: 'comment-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty commentId', () => { + const result = likeCommentSchema.safeParse({ commentId: '' }) + expect(result.success).toBe(false) + }) + + test('rejects commentId over 50 characters', () => { + const result = likeCommentSchema.safeParse({ commentId: 'a'.repeat(51) }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('评论ID格式无效') + } + }) +}) + +describe('checkCommentLikeStatusSchema', () => { + test('accepts valid commentId', () => { + const result = checkCommentLikeStatusSchema.safeParse({ commentId: 'comment-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty commentId', () => { + const result = checkCommentLikeStatusSchema.safeParse({ commentId: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('评论ID不能为空') + } + }) + + test('rejects commentId over 50 characters', () => { + const result = checkCommentLikeStatusSchema.safeParse({ commentId: 'a'.repeat(51) }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('评论ID格式无效') + } + }) +}) + +describe('checkMultipleCommentLikeStatusSchema', () => { + test('accepts valid commentIds array', () => { + const result = checkMultipleCommentLikeStatusSchema.safeParse({ + commentIds: ['id1', 'id2', 'id3'], + }) + expect(result.success).toBe(true) + }) + + test('rejects empty array', () => { + const result = checkMultipleCommentLikeStatusSchema.safeParse({ + commentIds: [], + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('评论ID列表不能为空') + } + }) + + test('rejects array over 100 items', () => { + const result = checkMultipleCommentLikeStatusSchema.safeParse({ + commentIds: Array(101).fill('id'), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('最多查询100条评论状态') + } + }) +}) \ No newline at end of file diff --git a/src/lib/validators/common.test.ts b/src/lib/validators/common.test.ts new file mode 100644 index 0000000..9f5462b --- /dev/null +++ b/src/lib/validators/common.test.ts @@ -0,0 +1,30 @@ +import { describe, test, expect } from 'vitest' +import { articleIdSchema } from './common' + +describe('articleIdSchema', () => { + test('accepts valid articleId', () => { + const result = articleIdSchema.safeParse('article-id') + expect(result.success).toBe(true) + }) + + test('accepts CUID format', () => { + const result = articleIdSchema.safeParse('clp123456789abcdefghij') + expect(result.success).toBe(true) + }) + + test('rejects empty articleId', () => { + const result = articleIdSchema.safeParse('') + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID不能为空') + } + }) + + test('rejects articleId over 50 characters', () => { + const result = articleIdSchema.safeParse('a'.repeat(51)) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID格式无效') + } + }) +}) \ No newline at end of file diff --git a/src/lib/validators/follow.test.ts b/src/lib/validators/follow.test.ts new file mode 100644 index 0000000..39b0aaf --- /dev/null +++ b/src/lib/validators/follow.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from 'vitest' +import { + followUserSchema, + getFollowersSchema, + getFollowingSchema, + checkFollowStatusSchema, + getFollowStatsSchema, +} from './follow' + +describe('followUserSchema', () => { + test('accepts valid userId', () => { + const result = followUserSchema.safeParse({ userId: 'user-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty userId', () => { + const result = followUserSchema.safeParse({ userId: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('用户ID不能为空') + } + }) +}) + +describe('getFollowersSchema', () => { + test('applies defaults', () => { + const result = getFollowersSchema.safeParse({ userId: 'user-id' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.page).toBe(1) + expect(result.data.limit).toBe(20) + } + }) + + test('accepts valid pagination', () => { + const result = getFollowersSchema.safeParse({ + userId: 'user-id', + page: 2, + limit: 30, + }) + expect(result.success).toBe(true) + }) + + test('rejects limit over 50', () => { + const result = getFollowersSchema.safeParse({ + userId: 'user-id', + limit: 51, + }) + expect(result.success).toBe(false) + }) +}) + +describe('getFollowingSchema', () => { + test('applies defaults', () => { + const result = getFollowingSchema.safeParse({ userId: 'user-id' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.page).toBe(1) + expect(result.data.limit).toBe(20) + } + }) +}) + +describe('checkFollowStatusSchema', () => { + test('accepts valid targetUserId', () => { + const result = checkFollowStatusSchema.safeParse({ targetUserId: 'target-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty targetUserId', () => { + const result = checkFollowStatusSchema.safeParse({ targetUserId: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('目标用户ID不能为空') + } + }) +}) + +describe('getFollowStatsSchema', () => { + test('accepts valid userId', () => { + const result = getFollowStatsSchema.safeParse({ userId: 'user-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty userId', () => { + const result = getFollowStatsSchema.safeParse({ userId: '' }) + expect(result.success).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/lib/validators/like.test.ts b/src/lib/validators/like.test.ts new file mode 100644 index 0000000..6707e65 --- /dev/null +++ b/src/lib/validators/like.test.ts @@ -0,0 +1,70 @@ +import { describe, test, expect } from 'vitest' +import { + likeArticleSchema, + checkLikeStatusSchema, + checkMultipleLikeStatusSchema, +} from './like' + +describe('likeArticleSchema', () => { + test('accepts valid articleId', () => { + const result = likeArticleSchema.safeParse({ articleId: 'article-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty articleId', () => { + const result = likeArticleSchema.safeParse({ articleId: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID不能为空') + } + }) +}) + +describe('checkLikeStatusSchema', () => { + test('accepts valid articleId', () => { + const result = checkLikeStatusSchema.safeParse({ articleId: 'article-id' }) + expect(result.success).toBe(true) + }) + + test('rejects empty articleId', () => { + const result = checkLikeStatusSchema.safeParse({ articleId: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID不能为空') + } + }) + + test('rejects articleId over 50 characters', () => { + const result = checkLikeStatusSchema.safeParse({ articleId: 'a'.repeat(51) }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('文章ID格式无效') + } + }) +}) + +describe('checkMultipleLikeStatusSchema', () => { + test('accepts valid articleIds array', () => { + const result = checkMultipleLikeStatusSchema.safeParse({ + articleIds: ['id1', 'id2', 'id3'], + }) + expect(result.success).toBe(true) + }) + + test('rejects empty array', () => { + const result = checkMultipleLikeStatusSchema.safeParse({ + articleIds: [], + }) + expect(result.success).toBe(false) + }) + + test('rejects array over 100 items', () => { + const result = checkMultipleLikeStatusSchema.safeParse({ + articleIds: Array(101).fill('id'), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('最多查询100篇文章状态') + } + }) +}) \ No newline at end of file diff --git a/src/lib/validators/profile.test.ts b/src/lib/validators/profile.test.ts new file mode 100644 index 0000000..e7890e6 --- /dev/null +++ b/src/lib/validators/profile.test.ts @@ -0,0 +1,79 @@ +import { describe, test, expect } from 'vitest' +import { profileSchema } from './profile' + +describe('profileSchema', () => { + test('accepts valid profile data', () => { + const result = profileSchema.safeParse({ + name: 'Test User', + image: 'https://example.com/avatar.jpg', + bio: 'Test bio', + pronouns: 'they', + }) + expect(result.success).toBe(true) + }) + + test('accepts empty image', () => { + const result = profileSchema.safeParse({ + name: 'Test User', + image: '', + }) + expect(result.success).toBe(true) + }) + + test('rejects name shorter than 2 characters', () => { + const result = profileSchema.safeParse({ + name: 'T', + image: '', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('姓名至少需要2个字符') + } + }) + + test('rejects name over 50 characters', () => { + const result = profileSchema.safeParse({ + name: 'a'.repeat(51), + image: '', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('姓名不能超过50个字符') + } + }) + + test('rejects invalid image URL', () => { + const result = profileSchema.safeParse({ + name: 'Test User', + image: 'invalid-url', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('请输入有效的图片URL') + } + }) + + test('rejects bio over 160 characters', () => { + const result = profileSchema.safeParse({ + name: 'Test User', + image: '', + bio: 'a'.repeat(161), + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('简介不能超过160个字符') + } + }) + + test('rejects pronouns over 4 characters', () => { + const result = profileSchema.safeParse({ + name: 'Test User', + image: '', + pronouns: 'theythem', + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('代称不能超过4个字符') + } + }) +}) \ No newline at end of file diff --git a/src/lib/validators/tag.test.ts b/src/lib/validators/tag.test.ts new file mode 100644 index 0000000..7410793 --- /dev/null +++ b/src/lib/validators/tag.test.ts @@ -0,0 +1,50 @@ +import { describe, test, expect } from 'vitest' +import { createTagSchema, tagSlugSchema } from './tag' + +describe('createTagSchema', () => { + test('accepts valid tag name', () => { + const result = createTagSchema.safeParse({ name: 'Test Tag' }) + expect(result.success).toBe(true) + }) + + test('accepts Chinese tag name', () => { + const result = createTagSchema.safeParse({ name: '测试标签' }) + expect(result.success).toBe(true) + }) + + test('rejects empty name', () => { + const result = createTagSchema.safeParse({ name: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('标签名不能为空') + } + }) + + test('rejects name over 30 characters', () => { + const result = createTagSchema.safeParse({ name: 'a'.repeat(31) }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('标签名不能超过30个字符') + } + }) +}) + +describe('tagSlugSchema', () => { + test('accepts valid slug', () => { + const result = tagSlugSchema.safeParse({ slug: 'test-tag' }) + expect(result.success).toBe(true) + }) + + test('accepts Chinese slug', () => { + const result = tagSlugSchema.safeParse({ slug: '测试标签' }) + expect(result.success).toBe(true) + }) + + test('rejects empty slug', () => { + const result = tagSlugSchema.safeParse({ slug: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('标签 slug 不能为空') + } + }) +}) \ No newline at end of file diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..fe9305f --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,15 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach, vi } from 'vitest' + +afterEach(() => { + cleanup() +}) + +// Mock server-only modules to prevent import errors in tests +vi.mock('@tanstack/react-start/server-only', () => ({})) + +// Mock environment variables +vi.stubEnv('DATABASE_URL', 'postgresql://test:test@localhost:5432/test') +vi.stubEnv('BETTER_AUTH_SECRET', 'test-secret-key-for-testing') +vi.stubEnv('BETTER_AUTH_URL', 'http://localhost:3000') \ No newline at end of file diff --git a/src/test/utils/test-utils.tsx b/src/test/utils/test-utils.tsx new file mode 100644 index 0000000..374df95 --- /dev/null +++ b/src/test/utils/test-utils.tsx @@ -0,0 +1,43 @@ +import { render } from '@testing-library/react' +import type { RenderOptions } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import type { ReactElement, ReactNode } from 'react' + +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: 0, + }, + mutations: { + retry: false, + }, + }, + }) +} + +interface CustomRenderOptions extends RenderOptions { + queryClient?: QueryClient +} + +export function renderWithProviders( + ui: ReactElement, + options?: CustomRenderOptions +) { + const { queryClient: customQueryClient, ...renderOptions } = options ?? {} + const queryClient = customQueryClient ?? createTestQueryClient() + + return render(ui, { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + ...renderOptions, + }) +} + +export * from '@testing-library/react' +export { renderWithProviders as render } \ No newline at end of file diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..372f6ef --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '@playwright/test' + +test.describe('Smoke Tests', () => { + test('home page renders', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('heading', { name: '让思想自由流淌' })).toBeVisible() + }) + + test('navigation to articles works', async ({ page }) => { + await page.goto('/') + await page.getByRole('link', { name: '阅览文章' }).click() + await expect(page).toHaveURL(/articles/) + }) + + test('sign-up page accessible', async ({ page }) => { + await page.goto('/sign-up') + await expect(page.locator('#email')).toBeVisible() + }) + + test('login page accessible', async ({ page }) => { + await page.goto('/login') + await expect(page.locator('#email')).toBeVisible() + }) + + test('login form has correct fields', async ({ page }) => { + await page.goto('/login') + await expect(page.locator('#email')).toBeVisible() + await expect(page.locator('#password')).toBeVisible() + await expect(page.getByRole('button', { name: '登录' })).toBeVisible() + }) +}) + +test.describe('Auth Redirect', () => { + test('protected routes redirect to login', async ({ page }) => { + await page.goto('/write') + await expect(page).toHaveURL(/login/) + }) + + test('settings redirects to login', async ({ page }) => { + await page.goto('/me/settings') + await expect(page).toHaveURL(/login/) + }) +}) + +test.describe('Articles', () => { + test('articles list page renders', async ({ page }) => { + await page.goto('/articles') + await expect(page.getByRole('link', { name: 'Cedium' })).toBeVisible() + }) + + test('about page renders', async ({ page }) => { + await page.goto('/about') + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + }) +}) \ No newline at end of file diff --git a/tests/e2e/fixtures/auth.ts b/tests/e2e/fixtures/auth.ts new file mode 100644 index 0000000..628082f --- /dev/null +++ b/tests/e2e/fixtures/auth.ts @@ -0,0 +1,56 @@ +import { test as base, expect, type Page } from '@playwright/test' +import { TEST_USER, TEST_AUTHOR } from '../helpers/auth' + +// Extend base test with authenticated fixtures +export const test = base.extend<{ + authenticatedPage: Page + authorPage: Page +}>({ + // Setup authenticated page for regular user + authenticatedPage: async ({ page }, use) => { + await registerAndLogin(page, TEST_USER) + await use(page) + }, + + // Setup authenticated page for author + authorPage: async ({ page }, use) => { + await registerAndLogin(page, TEST_AUTHOR) + await use(page) + }, +}) + +/** + * Register user via API and login + */ +async function registerAndLogin(page: Page, user: typeof TEST_USER) { + // Try to register via API (will fail if already exists, that's OK) + await page.request.post('/api/auth/sign-up/email', { + data: { + email: user.email, + password: user.password, + name: user.name, + }, + }) + + // Then login via UI + await page.goto('/login') + await page.locator('#email').fill(user.email) + await page.locator('#password').fill(user.password) + await page.getByRole('button', { name: '登录' }).click() + + // Wait for redirect (may stay on login if password wrong) + try { + await page.waitForURL('/', { timeout: 5000 }) + } catch { + // If login fails, user might already exist with different password + // Try signup flow instead + await page.goto('/sign-up') + await page.getByLabel(/姓名|name/i).fill(user.name) + await page.locator('#email').fill(user.email) + await page.locator('#password').fill(user.password) + await page.getByRole('button', { name: '注册' }).click() + await page.waitForURL('/', { timeout: 10000 }) + } +} + +export { expect } \ No newline at end of file diff --git a/tests/e2e/helpers/auth.ts b/tests/e2e/helpers/auth.ts new file mode 100644 index 0000000..6876164 --- /dev/null +++ b/tests/e2e/helpers/auth.ts @@ -0,0 +1,68 @@ +import { type Page } from '@playwright/test' + +// Test user credentials (must match prisma/seed.ts) +export const TEST_USER = { + email: 'test-user@cedium.test', + password: 'TestPassword123!', + name: 'Test User', +} + +export const TEST_AUTHOR = { + email: 'test-author@cedium.test', + password: 'AuthorPassword123!', + name: 'Test Author', +} + +/** + * Login a test user via the login page + */ +export async function login(page: Page, user: typeof TEST_USER = TEST_USER) { + await page.goto('/login') + + // Fill login form - use actual IDs from LoginForm component + await page.locator('#email').fill(user.email) + await page.locator('#password').fill(user.password) + + // Submit form + await page.getByRole('button', { name: '登录' }).click() + + // Wait for redirect to home + await page.waitForURL('/', { timeout: 10000 }) +} + +/** + * Logout the current user + */ +export async function logout(page: Page) { + // Navigate to settings or find logout button + await page.goto('/me/settings') + + // Find and click logout button + const logoutButton = page.getByRole('button', { name: /退出|logout|登出/i }) + if (await logoutButton.isVisible()) { + await logoutButton.click() + } + + await page.waitForURL('/') +} + +/** + * Create a new user via signup (for testing registration flow) + */ +export async function signup(page: Page, userData: { + name: string + email: string + password: string +}) { + await page.goto('/sign-up') + + await page.getByPlaceholder(/姓名|name/i).fill(userData.name) + await page.getByPlaceholder(/邮箱|email/i).fill(userData.email) + await page.getByPlaceholder(/密码|password/i).fill(userData.password) + + // Submit + await page.getByRole('button', { name: /注册|signup/i }).click() + + // Wait for redirect or verification page + await page.waitForURL(/\/|verify/, { timeout: 10000 }) +} \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..34a65e0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from 'vitest/config' +import { reactCompilerPreset } from '@vitejs/plugin-react' +import babel from '@rolldown/plugin-babel' +import path from 'path' + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/**', + 'src/test/**', + 'src/generated/**', + '**/*.d.ts', + '**/*.config.ts', + '**/index.ts', + ], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, + resolve: { + alias: { + '#': path.resolve(__dirname, './src'), + '@': path.resolve(__dirname, './src'), + }, + }, + plugins: [ + babel({ presets: [reactCompilerPreset()] }), + ], +}) \ No newline at end of file