@@ -270,7 +270,7 @@ export function SignupForm({ onSuccess, onError }: SignupFormProps) {
htmlFor="confirmPassword"
className="text-sm font-medium text-gray-700 flex items-center gap-2"
>
-
Confirmer le mot de passe
diff --git a/docs/TESTING.md b/docs/TESTING.md
new file mode 100644
index 00000000..9d02d54e
--- /dev/null
+++ b/docs/TESTING.md
@@ -0,0 +1,111 @@
+# Testing
+
+## Architecture
+
+```
+tests/
+├── setup.ts # Global mocks (router, EventSource, ResizeObserver)
+├── factories/ # Test data builders (buildUser, buildQuiz, ...)
+├── mocks/ # Shared mock helpers (session, router, axios)
+├── unit/
+│ ├── lib/ # Pure functions (no React)
+│ ├── hooks/ # Hooks via renderHook()
+│ └── components/ # Components via render() + userEvent
+└── e2e/ # Playwright browser tests
+```
+
+## Unit Tests (Vitest)
+
+### Pure functions
+
+Test direct input/output, no React needed.
+
+```ts
+import { shuffleQuiz } from "@/lib/utils/quiz";
+
+it("preserves the correct answer after shuffle", () => {
+ const [shuffled] = shuffleQuiz([question]);
+ expect(shuffled.answer).toContain("Paris");
+});
+```
+
+Reference: `tests/unit/lib/quiz.test.ts`
+
+### Hooks
+
+Use `renderHook` from `@testing-library/react`.
+
+```ts
+import { renderHook, act } from "@testing-library/react";
+
+const { result } = renderHook(() => useQuizPlayer({ quizData }));
+
+act(() => {
+ result.current.setSelectedAnswer("B) 4");
+});
+
+expect(result.current.score).toBe(1);
+```
+
+Reference: `tests/unit/hooks/useQuizPlayer.test.ts`
+
+### Components (RTL)
+
+Use `render`, `screen`, `userEvent.setup()`, and `waitFor` for async updates.
+
+```tsx
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+const user = userEvent.setup();
+
+render(
);
+
+await user.type(screen.getByPlaceholderText("ton.email@exemple.com"), "test@example.com");
+await user.click(screen.getByRole("button", { name: /se connecter/i }));
+
+await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalled();
+});
+```
+
+Mock external hooks at the top of the file:
+
+```ts
+vi.mock("@/hooks/useSession", () => ({
+ useSession: () => ({ login: mockLogin, user: null, loading: false }),
+}));
+```
+
+Reference: `tests/unit/components/auth/SigninForm.test.tsx`
+
+## E2E Tests (Playwright)
+
+Browser tests against the running app. Use accessible selectors (`getByRole`, `getByPlaceholder`).
+
+```ts
+import { test, expect } from "@playwright/test";
+
+test("should redirect unauthenticated users", async ({ page }) => {
+ await page.goto("/library");
+ await expect(page).toHaveURL(/auth/);
+});
+```
+
+Reference: `tests/e2e/auth.spec.ts`
+
+## Commands
+
+| Command | Description |
+|---|---|
+| `pnpm test` | Run unit tests (watch mode) |
+| `pnpm test:ci` | Run unit tests once + coverage |
+| `pnpm test:e2e` | Run Playwright E2E tests |
+| `pnpm test:e2e:headed` | Run E2E tests with visible browser |
+
+## CI/CD
+
+Configured in `.github/workflows/test.yml`:
+
+- **Every push**: unit tests + E2E tests
+- **PRs only**: coverage report posted as comment
diff --git a/package-lock.json b/package-lock.json
index fb4fc160..25dece55 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -58,6 +58,7 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20.17.6",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -3423,6 +3424,20 @@
}
}
},
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
diff --git a/package.json b/package.json
index f305ca85..0530df38 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"reset": "pkill -f 'next dev' 2>/dev/null || true && rm -rf .next && pnpm dev",
"test": "vitest",
"test:watch": "vitest --watch",
+ "test:ci": "vitest run --coverage",
"test:coverage": "vitest --coverage",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
@@ -73,6 +74,7 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20.17.6",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -80,6 +82,7 @@
"@typescript-eslint/parser": "^8.34.1",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "4.0.15",
+ "@vitest/ui": "4.0.15",
"eslint": "^8",
"eslint-config-next": "14.2.16",
"eslint-config-prettier": "^10.1.5",
diff --git a/playwright.config.ts b/playwright.config.ts
index 5785af27..d94e955c 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -15,7 +15,7 @@ export default defineConfig({
: "html",
use: {
- baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000",
+ baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
@@ -28,7 +28,9 @@ export default defineConfig({
],
webServer: {
- command: "pnpm dev",
+ command: process.env.CI
+ ? "pnpm start"
+ : "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120000,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f626b5b8..5c09877f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -153,6 +153,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^20.17.6
version: 20.19.1
@@ -173,7 +176,10 @@ importers:
version: 5.1.1(vite@7.2.6(@types/node@20.19.1)(jiti@1.21.7)(tsx@4.20.3)(yaml@2.8.0))
'@vitest/coverage-v8':
specifier: 4.0.15
- version: 4.0.15(vitest@4.0.15(@types/node@20.19.1)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0))
+ version: 4.0.15(vitest@4.0.15)
+ '@vitest/ui':
+ specifier: 4.0.15
+ version: 4.0.15(vitest@4.0.15)
eslint:
specifier: ^8
version: 8.57.1
@@ -209,7 +215,7 @@ importers:
version: 5.8.3
vitest:
specifier: ^4.0.15
- version: 4.0.15(@types/node@20.19.1)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0)
+ version: 4.0.15(@types/node@20.19.1)(@vitest/ui@4.0.15)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0)
packages:
@@ -755,6 +761,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ '@polka/url@1.0.0-next.29':
+ resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
'@puppeteer/browsers@2.10.8':
resolution: {integrity: sha512-f02QYEnBDE0p8cteNoPYHHjbDuwyfbe4cCIVlNi8/MRicIxFW4w4CfgU0LNgWEID6s06P+hRJ1qjpBLMhPRCiQ==}
engines: {node: '>=18'}
@@ -1558,6 +1567,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'
+
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
@@ -1833,6 +1848,11 @@ packages:
'@vitest/spy@4.0.15':
resolution: {integrity: sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==}
+ '@vitest/ui@4.0.15':
+ resolution: {integrity: sha512-sxSyJMaKp45zI0u+lHrPuZM1ZJQ8FaVD35k+UxVrha1yyvQ+TZuUYllUixwvQXlB7ixoDc7skf3lQPopZIvaQw==}
+ peerDependencies:
+ vitest: 4.0.15
+
'@vitest/utils@4.0.15':
resolution: {integrity: sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==}
@@ -2572,14 +2592,6 @@ packages:
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
- fdir@6.4.6:
- resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==}
- peerDependencies:
- picomatch: ^3 || ^4
- peerDependenciesMeta:
- picomatch:
- optional: true
-
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -3318,6 +3330,10 @@ packages:
motion-utils@12.23.6:
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
+ mrmime@2.0.1:
+ resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+ engines: {node: '>=10'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -3510,10 +3526,6 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
- picomatch@4.0.2:
- resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
- engines: {node: '>=12'}
-
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
@@ -3892,6 +3904,10 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ sirv@3.0.2:
+ resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
+ engines: {node: '>=18'}
+
smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
@@ -4092,10 +4108,6 @@ packages:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
- tinyglobby@0.2.14:
- resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
- engines: {node: '>=12.0.0'}
-
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -4115,6 +4127,10 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ totalist@3.0.1:
+ resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+ engines: {node: '>=6'}
+
tough-cookie@6.0.0:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
@@ -4921,6 +4937,8 @@ snapshots:
dependencies:
playwright: 1.55.1
+ '@polka/url@1.0.0-next.29': {}
+
'@puppeteer/browsers@2.10.8':
dependencies:
debug: 4.4.1
@@ -5692,6 +5710,10 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@tybys/wasm-util@0.9.0':
@@ -5946,7 +5968,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@vitest/coverage-v8@4.0.15(vitest@4.0.15(@types/node@20.19.1)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0))':
+ '@vitest/coverage-v8@4.0.15(vitest@4.0.15)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.15
@@ -5959,7 +5981,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
- vitest: 4.0.15(@types/node@20.19.1)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0)
+ vitest: 4.0.15(@types/node@20.19.1)(@vitest/ui@4.0.15)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0)
transitivePeerDependencies:
- supports-color
@@ -5997,6 +6019,17 @@ snapshots:
'@vitest/spy@4.0.15': {}
+ '@vitest/ui@4.0.15(vitest@4.0.15)':
+ dependencies:
+ '@vitest/utils': 4.0.15
+ fflate: 0.8.2
+ flatted: 3.3.3
+ pathe: 2.0.3
+ sirv: 3.0.2
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.0.3
+ vitest: 4.0.15(@types/node@20.19.1)(@vitest/ui@4.0.15)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0)
+
'@vitest/utils@4.0.15':
dependencies:
'@vitest/pretty-format': 4.0.15
@@ -6661,7 +6694,7 @@ snapshots:
'@typescript-eslint/parser': 8.35.0(eslint@8.57.1)(typescript@5.8.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -6685,7 +6718,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
@@ -6693,21 +6726,21 @@ snapshots:
get-tsconfig: 4.10.1
is-bun-module: 2.0.0
stable-hash: 0.0.5
- tinyglobby: 0.2.14
+ tinyglobby: 0.2.15
unrs-resolver: 1.9.2
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.35.0(eslint@8.57.1)(typescript@5.8.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
@@ -6722,7 +6755,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.35.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -6907,10 +6940,6 @@ snapshots:
dependencies:
pend: 1.2.0
- fdir@6.4.6(picomatch@4.0.2):
- optionalDependencies:
- picomatch: 4.0.2
-
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -7804,6 +7833,8 @@ snapshots:
motion-utils@12.23.6: {}
+ mrmime@2.0.1: {}
+
ms@2.1.3: {}
mz@2.7.0:
@@ -8009,8 +8040,6 @@ snapshots:
picomatch@2.3.1: {}
- picomatch@4.0.2: {}
-
picomatch@4.0.3: {}
pify@2.3.0: {}
@@ -8475,6 +8504,12 @@ snapshots:
signal-exit@4.1.0: {}
+ sirv@3.0.2:
+ dependencies:
+ '@polka/url': 1.0.0-next.29
+ mrmime: 2.0.1
+ totalist: 3.0.1
+
smart-buffer@4.2.0: {}
socks-proxy-agent@8.0.5:
@@ -8738,11 +8773,6 @@ snapshots:
tinyexec@1.0.2: {}
- tinyglobby@0.2.14:
- dependencies:
- fdir: 6.4.6(picomatch@4.0.2)
- picomatch: 4.0.2
-
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@@ -8760,6 +8790,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ totalist@3.0.1: {}
+
tough-cookie@6.0.0:
dependencies:
tldts: 7.0.19
@@ -8965,7 +8997,7 @@ snapshots:
tsx: 4.20.3
yaml: 2.8.0
- vitest@4.0.15(@types/node@20.19.1)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0):
+ vitest@4.0.15(@types/node@20.19.1)(@vitest/ui@4.0.15)(jiti@1.21.7)(jsdom@27.2.0)(tsx@4.20.3)(yaml@2.8.0):
dependencies:
'@vitest/expect': 4.0.15
'@vitest/mocker': 4.0.15(vite@7.2.6(@types/node@20.19.1)(jiti@1.21.7)(tsx@4.20.3)(yaml@2.8.0))
@@ -8989,6 +9021,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.1
+ '@vitest/ui': 4.0.15(vitest@4.0.15)
jsdom: 27.2.0
transitivePeerDependencies:
- jiti
diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts
index bee50825..cae16942 100644
--- a/tests/e2e/auth.spec.ts
+++ b/tests/e2e/auth.spec.ts
@@ -4,15 +4,10 @@ test.describe("Authentication Flow", () => {
test("should display login page", async ({ page }) => {
await page.goto("/auth");
- // Vérifier que la page d'authentification se charge
await expect(page).toHaveTitle(/Edukai/i);
-
- // Vérifier la présence du logo et titre Edukai
await expect(
page.getByRole("heading", { name: /Edukai/i })
).toBeVisible();
-
- // Vérifier la présence du logo
await expect(page.getByAltText(/Logo Edukai/i)).toBeVisible();
});
@@ -21,69 +16,61 @@ test.describe("Authentication Flow", () => {
}) => {
await page.goto("/auth");
- // Chercher le bouton de connexion et cliquer
const submitButton = page
- .getByRole("button", { name: /connexion|login|se connecter/i })
+ .getByRole("button", { name: /se connecter/i })
.first();
await submitButton.click();
- // Vérifier qu'on reste sur la page (pas de redirection)
await expect(page).toHaveURL(/auth/);
});
test("should redirect unauthenticated users from protected routes", async ({
page,
}) => {
- // Essayer d'accéder à une route protégée sans être authentifié
- await page.goto("/dashboard");
+ await page.goto("/library");
- // Devrait rediriger vers /auth
await expect(page).toHaveURL(/auth/);
});
test("should handle invalid credentials", async ({ page }) => {
await page.goto("/auth");
- // Remplir avec des identifiants invalides
- await page.fill(
- 'input[name="email"], input[type="email"]',
- "invalid@example.com"
- );
- await page.fill(
- 'input[name="password"], input[type="password"]',
- "wrongpassword"
- );
+ await page
+ .getByPlaceholder(/ton\.email/i)
+ .fill("invalid@example.com");
+ await page
+ .getByPlaceholder("••••••••")
+ .first()
+ .fill("wrongpassword");
- // Soumettre le formulaire
const submitButton = page
- .getByRole("button", { name: /connexion|login|se connecter/i })
+ .getByRole("button", { name: /se connecter/i })
.first();
await submitButton.click();
- // Attendre un message d'erreur ou rester sur la page
- await page.waitForTimeout(1000);
await expect(page).toHaveURL(/auth/);
});
});
test.describe("Registration Flow", () => {
- test("should display registration form", async ({ page }) => {
+ test("should display registration form when clicking register button", async ({
+ page,
+ }) => {
await page.goto("/auth");
- // Chercher un lien/bouton vers l'inscription
- const registerLink = page
- .getByRole("link", {
- name: /inscription|register|s'inscrire|créer un compte/i,
+ const registerButton = page
+ .getByRole("button", {
+ name: /créer un compte/i,
})
.first();
- if (await registerLink.isVisible()) {
- await registerLink.click();
- await expect(
- page.getByRole("heading", {
- name: /inscription|register|s'inscrire/i,
- })
- ).toBeVisible();
- }
+ await expect(registerButton).toBeVisible();
+ await registerButton.click();
+
+ await expect(
+ page.getByRole("heading", {
+ name: /commence ton aventure/i,
+ })
+ ).toBeVisible();
});
});
diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts
index 2c3f4605..0144224e 100644
--- a/tests/e2e/dashboard.spec.ts
+++ b/tests/e2e/dashboard.spec.ts
@@ -1,41 +1,24 @@
import { test, expect } from "@playwright/test";
-test.describe("Dashboard (Protected Routes)", () => {
- test.beforeEach(async ({ page }) => {
- // Ces tests vérifient le comportement sans authentification
- // Pour tester avec authentification, il faudra ajouter un helper de login
- await page.goto("/dashboard");
- });
-
- test("should redirect to auth when not logged in", async ({ page }) => {
- // Le middleware devrait rediriger vers /auth
+test.describe("Protected Routes", () => {
+ test("should redirect to auth when accessing library", async ({
+ page,
+ }) => {
+ await page.goto("/library");
await expect(page).toHaveURL(/auth/);
});
- test("should not access library without auth", async ({ page }) => {
- await page.goto("/dashboard/library");
-
- // Devrait rediriger vers /auth
+ test("should redirect to auth when accessing settings", async ({
+ page,
+ }) => {
+ await page.goto("/settings");
await expect(page).toHaveURL(/auth/);
});
- test("should not access settings without auth", async ({ page }) => {
- await page.goto("/dashboard/settings");
-
- // Devrait rediriger vers /auth
+ test("should redirect to auth when accessing profile", async ({
+ page,
+ }) => {
+ await page.goto("/profile");
await expect(page).toHaveURL(/auth/);
});
});
-
-// TODO: Ajouter des tests avec authentification
-// test.describe('Dashboard (Authenticated)', () => {
-// test.beforeEach(async ({ page }) => {
-// // Helper de login à créer
-// await loginAsUser(page, 'user@example.com', 'password');
-// });
-//
-// test('should access dashboard when authenticated', async ({ page }) => {
-// await page.goto('/dashboard');
-// await expect(page).toHaveURL(/dashboard/);
-// });
-// });
diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts
index 320d5fe2..0b0e2195 100644
--- a/tests/e2e/navigation.spec.ts
+++ b/tests/e2e/navigation.spec.ts
@@ -1,36 +1,18 @@
import { test, expect } from "@playwright/test";
test.describe("Navigation", () => {
- test("should load homepage successfully", async ({ page }) => {
+ test("should redirect unauthenticated root to auth page", async ({
+ page,
+ }) => {
await page.goto("/");
- // Vérifier que la page se charge
+ await expect(page).toHaveURL(/auth/);
await expect(page).toHaveTitle(/Edukai/i);
-
- // Vérifier la présence du logo ou du nom de l'application
- const heading = page.getByRole("heading", { level: 1 }).first();
- await expect(heading).toBeVisible();
- });
-
- test("should have working navigation links", async ({ page }) => {
- await page.goto("/");
-
- // Vérifier la présence d'un lien de navigation (ajuster selon votre navbar)
- const authLink = page
- .getByRole("link", { name: /connexion|login|auth/i })
- .first();
-
- if (await authLink.isVisible()) {
- await authLink.click();
- await expect(page).toHaveURL(/auth/);
- }
});
test("should handle non-existent pages gracefully", async ({ page }) => {
await page.goto("/this-page-does-not-exist-xyz123");
- // Vérifier que la page se charge sans erreur (peut être 404 ou redirection)
- // Next.js peut gérer cela de différentes façons selon la configuration
- await expect(page).toHaveTitle(/Edukai/i);
+ await expect(page).toHaveURL(/auth/);
});
});
diff --git a/tests/factories/course.ts b/tests/factories/course.ts
new file mode 100644
index 00000000..4f356962
--- /dev/null
+++ b/tests/factories/course.ts
@@ -0,0 +1,28 @@
+export interface CourseData {
+ _id: string;
+ title: string;
+ subject: string;
+ level: string;
+ visibility: "public" | "private";
+ userId: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+let courseIdCounter = 0;
+
+export function buildCourse(overrides?: Partial
): CourseData {
+ courseIdCounter++;
+ const now = new Date().toISOString();
+ return {
+ _id: `course-${courseIdCounter}`,
+ title: `Test Course ${courseIdCounter}`,
+ subject: "Informatique",
+ level: "Licence 1",
+ visibility: "private",
+ userId: "user-1",
+ createdAt: now,
+ updatedAt: now,
+ ...overrides,
+ };
+}
diff --git a/tests/factories/index.ts b/tests/factories/index.ts
new file mode 100644
index 00000000..00c7afd8
--- /dev/null
+++ b/tests/factories/index.ts
@@ -0,0 +1,4 @@
+export { buildUser, buildPremiumUser, buildAdminUser } from "./user";
+export { buildQuizQuestion, buildQuiz } from "./quiz";
+export { buildCourse } from "./course";
+export type { CourseData } from "./course";
diff --git a/tests/factories/quiz.ts b/tests/factories/quiz.ts
new file mode 100644
index 00000000..d243cf29
--- /dev/null
+++ b/tests/factories/quiz.ts
@@ -0,0 +1,25 @@
+import type { QuizQuestion } from "@/lib/utils/quiz";
+
+let questionCounter = 0;
+
+export function buildQuizQuestion(
+ overrides?: Partial
+): QuizQuestion {
+ questionCounter++;
+ return {
+ question: `Question ${questionCounter}?`,
+ choices: [
+ `A) Answer A${questionCounter}`,
+ `B) Answer B${questionCounter}`,
+ `C) Answer C${questionCounter}`,
+ `D) Answer D${questionCounter}`,
+ ],
+ answer: `A) Answer A${questionCounter}`,
+ explanation: `Explanation for question ${questionCounter}`,
+ ...overrides,
+ };
+}
+
+export function buildQuiz(count: number = 5): QuizQuestion[] {
+ return Array.from({ length: count }, () => buildQuizQuestion());
+}
diff --git a/tests/factories/user.ts b/tests/factories/user.ts
new file mode 100644
index 00000000..6e7ac333
--- /dev/null
+++ b/tests/factories/user.ts
@@ -0,0 +1,36 @@
+import type { ApiUser } from "@/lib/schemas/user";
+
+let userIdCounter = 0;
+
+export function buildUser(overrides?: Partial): ApiUser {
+ userIdCounter++;
+ return {
+ _id: `user-${userIdCounter}`,
+ email: `user${userIdCounter}@test.com`,
+ username: `testuser${userIdCounter}`,
+ firstName: "Test",
+ lastName: "User",
+ profilePic: undefined,
+ grade: "Licence 1 - Informatique",
+ levelOfStudy: "superieur",
+ institution: "Test University",
+ accountPlan: "free",
+ role: "user",
+ ...overrides,
+ };
+}
+
+export function buildPremiumUser(overrides?: Partial): ApiUser {
+ return buildUser({
+ accountPlan: "premium",
+ ...overrides,
+ });
+}
+
+export function buildAdminUser(overrides?: Partial): ApiUser {
+ return buildUser({
+ role: "admin",
+ accountPlan: "premium",
+ ...overrides,
+ });
+}
diff --git a/tests/mocks/axios.ts b/tests/mocks/axios.ts
new file mode 100644
index 00000000..8fad3fc3
--- /dev/null
+++ b/tests/mocks/axios.ts
@@ -0,0 +1,64 @@
+import axios from "axios";
+import { vi } from "vitest";
+
+/**
+ * Mock a successful axios.get response.
+ */
+export function mockAxiosGet(data: T, status: number = 200): void {
+ vi.mocked(axios.get).mockResolvedValue({ data, status });
+}
+
+/**
+ * Mock a successful axios.post response.
+ */
+export function mockAxiosPost(data: T, status: number = 200): void {
+ vi.mocked(axios.post).mockResolvedValue({ data, status });
+}
+
+/**
+ * Mock a successful axios.put response.
+ */
+export function mockAxiosPut(data: T, status: number = 200): void {
+ vi.mocked(axios.put).mockResolvedValue({ data, status });
+}
+
+/**
+ * Mock a successful axios.delete response.
+ */
+export function mockAxiosDelete(data: T, status: number = 200): void {
+ vi.mocked(axios.delete).mockResolvedValue({ data, status });
+}
+
+/**
+ * Mock an axios error response (rejected promise with status code).
+ *
+ * @example
+ * ```ts
+ * mockAxiosError("post", 401, "Unauthorized");
+ * ```
+ */
+export function mockAxiosError(
+ method: "get" | "post" | "put" | "delete",
+ status: number,
+ message: string = "Error"
+): void {
+ vi.mocked(axios[method]).mockRejectedValue({
+ response: {
+ status,
+ data: { message },
+ },
+ message,
+ });
+}
+
+/**
+ * Mock an axios network error (no response object).
+ */
+export function mockAxiosNetworkError(
+ method: "get" | "post" | "put" | "delete"
+): void {
+ vi.mocked(axios[method]).mockRejectedValue({
+ message: "Network Error",
+ code: "NETWORK_ERROR",
+ });
+}
diff --git a/tests/mocks/index.ts b/tests/mocks/index.ts
new file mode 100644
index 00000000..1431f881
--- /dev/null
+++ b/tests/mocks/index.ts
@@ -0,0 +1,21 @@
+export {
+ mockAuthenticatedSession,
+ mockUnauthenticatedSession,
+ mockLoadingSession,
+ setupSessionMock,
+} from "./session";
+
+export {
+ createRouterMock,
+ getRouterMock,
+ setupRouterMock,
+} from "./router";
+
+export {
+ mockAxiosGet,
+ mockAxiosPost,
+ mockAxiosPut,
+ mockAxiosDelete,
+ mockAxiosError,
+ mockAxiosNetworkError,
+} from "./axios";
diff --git a/tests/mocks/router.ts b/tests/mocks/router.ts
new file mode 100644
index 00000000..bc49b58b
--- /dev/null
+++ b/tests/mocks/router.ts
@@ -0,0 +1,67 @@
+import { vi } from "vitest";
+
+interface RouterMock {
+ push: ReturnType;
+ replace: ReturnType;
+ prefetch: ReturnType;
+ back: ReturnType;
+ forward: ReturnType;
+ refresh: ReturnType;
+}
+
+let routerMock: RouterMock;
+
+export function createRouterMock(overrides?: Partial): RouterMock {
+ routerMock = {
+ push: vi.fn(),
+ replace: vi.fn(),
+ prefetch: vi.fn(),
+ back: vi.fn(),
+ forward: vi.fn(),
+ refresh: vi.fn(),
+ ...overrides,
+ };
+ return routerMock;
+}
+
+/**
+ * Get the current router mock to assert on its calls.
+ *
+ * @example
+ * ```ts
+ * expect(getRouterMock().push).toHaveBeenCalledWith("/auth");
+ * ```
+ */
+export function getRouterMock(): RouterMock {
+ return routerMock;
+}
+
+/**
+ * Setup the Next.js navigation mock for a test file.
+ * Call this in beforeEach to get a fresh router.
+ *
+ * @example
+ * ```ts
+ * vi.mock("next/navigation");
+ * import { setupRouterMock, getRouterMock } from "@/tests/mocks/router";
+ *
+ * beforeEach(() => {
+ * setupRouterMock();
+ * });
+ *
+ * it("redirects", () => {
+ * expect(getRouterMock().push).toHaveBeenCalledWith("/login");
+ * });
+ * ```
+ */
+export function setupRouterMock(
+ overrides?: Partial,
+ pathname: string = "/",
+ searchParams: URLSearchParams = new URLSearchParams()
+): void {
+ const router = createRouterMock(overrides);
+ const navigation = require("next/navigation");
+ vi.mocked(navigation.useRouter).mockReturnValue(router);
+ vi.mocked(navigation.usePathname).mockReturnValue(pathname);
+ vi.mocked(navigation.useSearchParams).mockReturnValue(searchParams);
+}
diff --git a/tests/mocks/session.ts b/tests/mocks/session.ts
new file mode 100644
index 00000000..1c59f069
--- /dev/null
+++ b/tests/mocks/session.ts
@@ -0,0 +1,68 @@
+import { vi } from "vitest";
+import { buildUser } from "../factories/user";
+import type { ApiUser } from "@/lib/schemas/user";
+
+interface SessionMock {
+ user: ApiUser | null;
+ loading: boolean;
+ login: ReturnType;
+ register: ReturnType;
+ logout: ReturnType;
+ validateSession: ReturnType;
+ refreshUserProfile: ReturnType;
+}
+
+function createSessionMock(overrides?: Partial): SessionMock {
+ return {
+ user: null,
+ loading: false,
+ login: vi.fn().mockResolvedValue({ success: true }),
+ register: vi.fn().mockResolvedValue({ success: true }),
+ logout: vi.fn().mockResolvedValue(undefined),
+ validateSession: vi.fn().mockResolvedValue(undefined),
+ refreshUserProfile: vi.fn().mockResolvedValue(undefined),
+ ...overrides,
+ };
+}
+
+export function mockAuthenticatedSession(
+ user?: Partial
+): SessionMock {
+ return createSessionMock({
+ user: buildUser(user),
+ loading: false,
+ });
+}
+
+export function mockUnauthenticatedSession(): SessionMock {
+ return createSessionMock({
+ user: null,
+ loading: false,
+ });
+}
+
+export function mockLoadingSession(): SessionMock {
+ return createSessionMock({
+ user: null,
+ loading: true,
+ });
+}
+
+/**
+ * Setup the useSession mock for a test file.
+ * Call this in beforeEach or at the top of a describe block.
+ *
+ * @example
+ * ```ts
+ * vi.mock("@/hooks/useSession");
+ * import { setupSessionMock } from "@/tests/mocks/session";
+ *
+ * beforeEach(() => {
+ * setupSessionMock(mockAuthenticatedSession({ role: "admin" }));
+ * });
+ * ```
+ */
+export function setupSessionMock(session: SessionMock): void {
+ const { useSession } = require("@/hooks/useSession");
+ vi.mocked(useSession).mockReturnValue(session);
+}
diff --git a/tests/setup.ts b/tests/setup.ts
index c9ae8189..c7e1c63e 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -1,6 +1,13 @@
import "@testing-library/jest-dom";
import { vi } from "vitest";
+// Polyfill ResizeObserver for Radix UI components in jsdom
+global.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+};
+
// Mock Next.js router
vi.mock("next/navigation", () => ({
useRouter: () => ({
@@ -28,6 +35,8 @@ Object.defineProperty(window, "location", {
});
// Mock EventSource for SSE tests
+let eventSourceInstances: MockEventSource[] = [];
+
class MockEventSource {
static CONNECTING = 0;
static OPEN = 1;
@@ -46,6 +55,7 @@ class MockEventSource {
constructor(url: string, options?: { withCredentials?: boolean }) {
this.url = url;
this.withCredentials = options?.withCredentials ?? false;
+ eventSourceInstances.push(this);
}
addEventListener(
@@ -100,9 +110,21 @@ class MockEventSource {
listeners.forEach(listener => listener(event));
}
}
+
+ simulateProgressEvent(data: unknown): void {
+ this.simulateMessage("progress", data);
+ }
+}
+
+function resetEventSourceInstances(): void {
+ eventSourceInstances.forEach(es => es.close());
+ eventSourceInstances = [];
+}
+
+function getEventSourceInstances(): MockEventSource[] {
+ return eventSourceInstances;
}
vi.stubGlobal("EventSource", MockEventSource);
-// Export for use in tests
-export { MockEventSource };
+export { MockEventSource, getEventSourceInstances, resetEventSourceInstances };
diff --git a/tests/unit/components/auth/AuthGuard.test.tsx b/tests/unit/components/auth/AuthGuard.test.tsx
new file mode 100644
index 00000000..e1e1fee8
--- /dev/null
+++ b/tests/unit/components/auth/AuthGuard.test.tsx
@@ -0,0 +1,117 @@
+/**
+ * AuthGuard Component Tests
+ *
+ * Demonstrates: conditional rendering based on hook state.
+ * Pattern: mock a custom hook to control component branches.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { AuthGuard } from "@/components/auth/AuthGuard";
+
+// Mock the hook that controls AuthGuard behavior
+const mockUseAuthGuard = vi.fn();
+vi.mock("@/hooks/useAuthGuard", () => ({
+ useAuthGuard: (...args: unknown[]) => mockUseAuthGuard(...args),
+}));
+
+describe("AuthGuard", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders children when user is authenticated", () => {
+ mockUseAuthGuard.mockReturnValue({
+ user: { id: "user-1", name: "Test" },
+ loading: false,
+ });
+
+ render(
+
+ Protected content
+
+ );
+
+ expect(screen.getByText("Protected content")).toBeInTheDocument();
+ });
+
+ it("renders loading state while session is loading", () => {
+ mockUseAuthGuard.mockReturnValue({
+ user: null,
+ loading: true,
+ });
+
+ render(
+
+ Protected content
+
+ );
+
+ expect(screen.getByText("Chargement...")).toBeInTheDocument();
+ expect(screen.queryByText("Protected content")).not.toBeInTheDocument();
+ });
+
+ it("renders custom fallback when provided and loading", () => {
+ mockUseAuthGuard.mockReturnValue({
+ user: null,
+ loading: true,
+ });
+
+ render(
+ Custom loader}>
+ Protected content
+
+ );
+
+ expect(screen.getByText("Custom loader")).toBeInTheDocument();
+ expect(screen.queryByText("Chargement...")).not.toBeInTheDocument();
+ });
+
+ it("renders nothing when user is null and not loading (redirect pending)", () => {
+ mockUseAuthGuard.mockReturnValue({
+ user: null,
+ loading: false,
+ });
+
+ const { container } = render(
+
+ Protected content
+
+ );
+
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("passes redirectTo to useAuthGuard", () => {
+ mockUseAuthGuard.mockReturnValue({
+ user: { id: "user-1" },
+ loading: false,
+ });
+
+ render(
+
+ Content
+
+ );
+
+ expect(mockUseAuthGuard).toHaveBeenCalledWith({
+ redirectTo: "/login",
+ });
+ });
+
+ it("defaults redirectTo to /auth", () => {
+ mockUseAuthGuard.mockReturnValue({
+ user: { id: "user-1" },
+ loading: false,
+ });
+
+ render(
+
+ Content
+
+ );
+
+ expect(mockUseAuthGuard).toHaveBeenCalledWith({
+ redirectTo: "/auth",
+ });
+ });
+});
diff --git a/tests/unit/components/auth/SigninForm.test.tsx b/tests/unit/components/auth/SigninForm.test.tsx
new file mode 100644
index 00000000..bd83a51c
--- /dev/null
+++ b/tests/unit/components/auth/SigninForm.test.tsx
@@ -0,0 +1,243 @@
+/**
+ * SigninForm Component Tests
+ *
+ * Demonstrates: form interactions with userEvent, Zod validation,
+ * async submit handling, and callback verification.
+ * Pattern: mock useSession, use userEvent.setup() for realistic interactions.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { SigninForm } from "@/components/auth/signin-form";
+
+// Mock useSession -- the only external dependency
+const mockLogin = vi.fn();
+vi.mock("@/hooks/useSession", () => ({
+ useSession: () => ({
+ login: mockLogin,
+ user: null,
+ loading: false,
+ }),
+}));
+
+describe("SigninForm", () => {
+ const user = userEvent.setup();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── Rendering ──────────────────────────────────────
+
+ it("renders the form with email and password fields", () => {
+ render();
+
+ expect(
+ screen.getByPlaceholderText("ton.email@exemple.com")
+ ).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("••••••••")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /se connecter/i })
+ ).toBeInTheDocument();
+ });
+
+ it("renders the header text", () => {
+ render();
+
+ expect(
+ screen.getByText("Content de te revoir !")
+ ).toBeInTheDocument();
+ });
+
+ it("renders forgot password button", () => {
+ render();
+
+ expect(
+ screen.getByRole("button", { name: /mot de passe oublié/i })
+ ).toBeInTheDocument();
+ });
+
+ // ── Validation ─────────────────────────────────────
+
+ it("shows validation error for empty email", async () => {
+ render();
+
+ // Only fill password, leave email empty
+ await user.type(
+ screen.getByPlaceholderText("••••••••"),
+ "password123"
+ );
+ await user.click(
+ screen.getByRole("button", { name: /se connecter/i })
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Adresse email invalide")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows validation error for short password", async () => {
+ render();
+
+ await user.type(
+ screen.getByPlaceholderText("ton.email@exemple.com"),
+ "test@example.com"
+ );
+ await user.type(screen.getByPlaceholderText("••••••••"), "abc");
+ await user.click(
+ screen.getByRole("button", { name: /se connecter/i })
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/au moins 6 caractères/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("does not call login when form is invalid", async () => {
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: /se connecter/i })
+ );
+
+ await waitFor(() => {
+ expect(mockLogin).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Successful submit ──────────────────────────────
+
+ it("calls login with credentials on valid submit", async () => {
+ mockLogin.mockResolvedValue({ success: true });
+ const onSuccess = vi.fn();
+
+ render();
+
+ await user.type(
+ screen.getByPlaceholderText("ton.email@exemple.com"),
+ "test@example.com"
+ );
+ await user.type(
+ screen.getByPlaceholderText("••••••••"),
+ "password123"
+ );
+ await user.click(
+ screen.getByRole("button", { name: /se connecter/i })
+ );
+
+ await waitFor(() => {
+ expect(mockLogin).toHaveBeenCalledWith({
+ username: "test@example.com",
+ password: "password123",
+ });
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalled();
+ });
+ });
+
+ // ── Failed submit ──────────────────────────────────
+
+ it("shows error message on login failure", async () => {
+ mockLogin.mockResolvedValue({
+ success: false,
+ error: "Identifiants incorrects",
+ });
+ const onError = vi.fn();
+
+ render();
+
+ await user.type(
+ screen.getByPlaceholderText("ton.email@exemple.com"),
+ "test@example.com"
+ );
+ await user.type(
+ screen.getByPlaceholderText("••••••••"),
+ "wrongpassword"
+ );
+ await user.click(
+ screen.getByRole("button", { name: /se connecter/i })
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Identifiants incorrects")
+ ).toBeInTheDocument();
+ });
+
+ expect(onError).toHaveBeenCalledWith("Identifiants incorrects");
+ });
+
+ it("shows generic error message on unexpected exception", async () => {
+ mockLogin.mockRejectedValue(new Error("Network error"));
+ const onError = vi.fn();
+
+ render();
+
+ await user.type(
+ screen.getByPlaceholderText("ton.email@exemple.com"),
+ "test@example.com"
+ );
+ await user.type(
+ screen.getByPlaceholderText("••••••••"),
+ "password123"
+ );
+ await user.click(
+ screen.getByRole("button", { name: /se connecter/i })
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Une erreur inattendue est survenue")
+ ).toBeInTheDocument();
+ });
+
+ expect(onError).toHaveBeenCalledWith(
+ "Une erreur inattendue est survenue"
+ );
+ });
+
+ // ── Password visibility toggle ─────────────────────
+
+ it("toggles password visibility", async () => {
+ render();
+
+ const passwordInput = screen.getByPlaceholderText("••••••••");
+ expect(passwordInput).toHaveAttribute("type", "password");
+
+ // The toggle button is inside the password field container
+ // It's the button that is NOT the submit button and NOT "forgot password"
+ const buttons = screen.getAllByRole("button");
+ const toggleButton = buttons.find(
+ btn =>
+ !btn.textContent?.includes("connecter") &&
+ !btn.textContent?.includes("oublié")
+ );
+ expect(toggleButton).toBeDefined();
+
+ await user.click(toggleButton!);
+ expect(passwordInput).toHaveAttribute("type", "text");
+
+ await user.click(toggleButton!);
+ expect(passwordInput).toHaveAttribute("type", "password");
+ });
+
+ // ── Forgot password callback ───────────────────────
+
+ it("calls onForgotPassword when forgot password is clicked", async () => {
+ const onForgotPassword = vi.fn();
+
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: /mot de passe oublié/i })
+ );
+
+ expect(onForgotPassword).toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/components/auth/SignupForm.test.tsx b/tests/unit/components/auth/SignupForm.test.tsx
new file mode 100644
index 00000000..428c6bd3
--- /dev/null
+++ b/tests/unit/components/auth/SignupForm.test.tsx
@@ -0,0 +1,234 @@
+/**
+ * SignupForm Component Tests
+ *
+ * Demonstrates: complex form validation with Zod refinements,
+ * live password requirements UI, and async registration flow.
+ * Pattern: test visual feedback (password strength), cross-field validation.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { SignupForm } from "@/components/auth/signup-form";
+
+const mockRegister = vi.fn();
+vi.mock("@/hooks/useSession", () => ({
+ useSession: () => ({
+ register: mockRegister,
+ user: null,
+ loading: false,
+ }),
+}));
+
+describe("SignupForm", () => {
+ const user = userEvent.setup();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── Rendering ──────────────────────────────────────
+
+ it("renders all registration fields", () => {
+ render();
+
+ expect(screen.getByPlaceholderText("John")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Doe")).toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText("john.doe@exemple.com")
+ ).toBeInTheDocument();
+ // Two password fields (password + confirm)
+ const passwordInputs = screen.getAllByPlaceholderText("••••••••");
+ expect(passwordInputs).toHaveLength(2);
+ });
+
+ it("renders the header", () => {
+ render();
+ expect(
+ screen.getByText("Commence ton aventure")
+ ).toBeInTheDocument();
+ });
+
+ // ── Validation errors ──────────────────────────────
+
+ it("shows validation errors on empty submit", async () => {
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: /créer mon compte/i })
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/prénom requis/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows email validation error for empty email", async () => {
+ render();
+
+ await user.type(screen.getByPlaceholderText("John"), "Test");
+ await user.type(screen.getByPlaceholderText("Doe"), "User");
+ // Leave email empty
+ await user.type(
+ screen.getAllByPlaceholderText("••••••••")[0],
+ "Test123"
+ );
+ await user.type(
+ screen.getAllByPlaceholderText("••••••••")[1],
+ "Test123"
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: /créer mon compte/i })
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Adresse email invalide")
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("shows password mismatch error", async () => {
+ render();
+
+ await user.type(screen.getByPlaceholderText("John"), "Test");
+ await user.type(screen.getByPlaceholderText("Doe"), "User");
+ await user.type(
+ screen.getByPlaceholderText("john.doe@exemple.com"),
+ "test@example.com"
+ );
+ await user.type(
+ screen.getAllByPlaceholderText("••••••••")[0],
+ "Test123"
+ );
+ await user.type(
+ screen.getAllByPlaceholderText("••••••••")[1],
+ "Different1"
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: /créer mon compte/i })
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/mots de passe ne correspondent pas/i)
+ ).toBeInTheDocument();
+ });
+ });
+
+ // ── Password requirements ──────────────────────────
+
+ it("shows password requirements when typing password", async () => {
+ render();
+
+ const passwordInput = screen.getAllByPlaceholderText("••••••••")[0];
+ await user.type(passwordInput, "a");
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Au moins 6 caractères")
+ ).toBeInTheDocument();
+ expect(screen.getByText("Une majuscule")).toBeInTheDocument();
+ expect(screen.getByText("Une minuscule")).toBeInTheDocument();
+ expect(screen.getByText("Un chiffre")).toBeInTheDocument();
+ });
+ });
+
+ // ── Successful registration ────────────────────────
+
+ it("calls register with correct data on valid submit", async () => {
+ mockRegister.mockResolvedValue({ success: true });
+ const onSuccess = vi.fn();
+
+ render();
+
+ await user.type(screen.getByPlaceholderText("John"), "Test");
+ await user.type(screen.getByPlaceholderText("Doe"), "User");
+ await user.type(
+ screen.getByPlaceholderText("john.doe@exemple.com"),
+ "test@example.com"
+ );
+ await user.type(
+ screen.getAllByPlaceholderText("••••••••")[0],
+ "Test123"
+ );
+ await user.type(
+ screen.getAllByPlaceholderText("••••••••")[1],
+ "Test123"
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: /créer mon compte/i })
+ );
+
+ await waitFor(() => {
+ expect(mockRegister).toHaveBeenCalledWith({
+ username: "test@example.com",
+ password: "Test123",
+ email: "test@example.com",
+ firstName: "Test",
+ lastName: "User",
+ });
+ });
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalled();
+ });
+ });
+
+ // ── Failed registration ────────────────────────────
+
+ it("shows error on registration failure", async () => {
+ mockRegister.mockResolvedValue({
+ success: false,
+ error: "Email déjà utilisé",
+ });
+ const onError = vi.fn();
+
+ render();
+
+ await user.type(screen.getByPlaceholderText("John"), "Test");
+ await user.type(screen.getByPlaceholderText("Doe"), "User");
+ await user.type(
+ screen.getByPlaceholderText("john.doe@exemple.com"),
+ "test@example.com"
+ );
+ await user.type(
+ screen.getAllByPlaceholderText("••••••••")[0],
+ "Test123"
+ );
+ await user.type(
+ screen.getAllByPlaceholderText("••••••••")[1],
+ "Test123"
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: /créer mon compte/i })
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Email déjà utilisé")
+ ).toBeInTheDocument();
+ });
+
+ expect(onError).toHaveBeenCalledWith("Email déjà utilisé");
+ });
+
+ // ── Does not submit when invalid ───────────────────
+
+ it("does not call register when form has validation errors", async () => {
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: /créer mon compte/i })
+ );
+
+ await waitFor(() => {
+ expect(mockRegister).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/tests/unit/hooks/useGenerationProgress.test.ts b/tests/unit/hooks/useGenerationProgress.test.ts
index c1b4446a..dd4000f4 100644
--- a/tests/unit/hooks/useGenerationProgress.test.ts
+++ b/tests/unit/hooks/useGenerationProgress.test.ts
@@ -1,94 +1,22 @@
import { renderHook, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useGenerationProgress } from "@/hooks/useGenerationProgress";
-
-// Store EventSource instances for testing
-let eventSourceInstances: MockEventSource[] = [];
-
-// Mock EventSource class
-class MockEventSource {
- static CONNECTING = 0;
- static OPEN = 1;
- static CLOSED = 2;
-
- url: string;
- withCredentials: boolean;
- readyState: number = MockEventSource.CONNECTING;
- onopen: ((event: Event) => void) | null = null;
- onerror: ((event: Event) => void) | null = null;
- onmessage: ((event: MessageEvent) => void) | null = null;
-
- private listeners: Map void)[]> =
- new Map();
-
- constructor(url: string, options?: { withCredentials?: boolean }) {
- this.url = url;
- this.withCredentials = options?.withCredentials ?? false;
- eventSourceInstances.push(this);
- }
-
- addEventListener(
- type: string,
- listener: (event: MessageEvent) => void
- ): void {
- if (!this.listeners.has(type)) {
- this.listeners.set(type, []);
- }
- this.listeners.get(type)!.push(listener);
- }
-
- removeEventListener(
- type: string,
- listener: (event: MessageEvent) => void
- ): void {
- const listeners = this.listeners.get(type);
- if (listeners) {
- const index = listeners.indexOf(listener);
- if (index !== -1) {
- listeners.splice(index, 1);
- }
- }
- }
-
- close(): void {
- this.readyState = MockEventSource.CLOSED;
- }
-
- // Helper methods for testing
- simulateOpen(): void {
- this.readyState = MockEventSource.OPEN;
- this.onopen?.(new Event("open"));
- }
-
- simulateError(): void {
- this.onerror?.(new Event("error"));
- }
-
- simulateProgressEvent(data: unknown): void {
- const event = new MessageEvent("progress", {
- data: JSON.stringify(data),
- });
- const listeners = this.listeners.get("progress");
- if (listeners) {
- listeners.forEach(listener => listener(event));
- }
- }
-}
-
-// Override global EventSource
-vi.stubGlobal("EventSource", MockEventSource);
+import {
+ MockEventSource,
+ getEventSourceInstances,
+ resetEventSourceInstances,
+} from "@/tests/setup";
describe("useGenerationProgress", () => {
beforeEach(() => {
vi.clearAllMocks();
- eventSourceInstances = [];
+ resetEventSourceInstances();
vi.stubEnv("NEXT_PUBLIC_API_URL", "http://localhost:3000/api");
});
afterEach(() => {
vi.unstubAllEnvs();
- eventSourceInstances.forEach(es => es.close());
- eventSourceInstances = [];
+ resetEventSourceInstances();
});
describe("initial state", () => {
@@ -109,7 +37,7 @@ describe("useGenerationProgress", () => {
it("should not create EventSource when jobId is null", () => {
renderHook(() => useGenerationProgress({ jobId: null }));
- expect(eventSourceInstances.length).toBe(0);
+ expect(getEventSourceInstances().length).toBe(0);
});
});
@@ -117,11 +45,11 @@ describe("useGenerationProgress", () => {
it("should create EventSource with correct URL when jobId is provided", () => {
renderHook(() => useGenerationProgress({ jobId: "test-job-123" }));
- expect(eventSourceInstances.length).toBe(1);
- expect(eventSourceInstances[0].url).toBe(
+ expect(getEventSourceInstances().length).toBe(1);
+ expect(getEventSourceInstances()[0].url).toBe(
"http://localhost:3000/api/progress/test-job-123"
);
- expect(eventSourceInstances[0].withCredentials).toBe(true);
+ expect(getEventSourceInstances()[0].withCredentials).toBe(true);
});
it("should set isConnected to true when connection opens", async () => {
@@ -130,7 +58,7 @@ describe("useGenerationProgress", () => {
);
act(() => {
- eventSourceInstances[0].simulateOpen();
+ getEventSourceInstances()[0].simulateOpen();
});
await waitFor(() => {
@@ -144,14 +72,14 @@ describe("useGenerationProgress", () => {
{ initialProps: { jobId: "job-1" } }
);
- expect(eventSourceInstances.length).toBe(1);
- const firstInstance = eventSourceInstances[0];
+ expect(getEventSourceInstances().length).toBe(1);
+ const firstInstance = getEventSourceInstances()[0];
rerender({ jobId: "job-2" });
expect(firstInstance.readyState).toBe(MockEventSource.CLOSED);
- expect(eventSourceInstances.length).toBe(2);
- expect(eventSourceInstances[1].url).toContain("job-2");
+ expect(getEventSourceInstances().length).toBe(2);
+ expect(getEventSourceInstances()[1].url).toContain("job-2");
});
});
@@ -162,7 +90,7 @@ describe("useGenerationProgress", () => {
);
act(() => {
- eventSourceInstances[0].simulateProgressEvent({
+ getEventSourceInstances()[0].simulateProgressEvent({
progress: 50,
message: "Processing...",
step: "processing",
@@ -187,7 +115,7 @@ describe("useGenerationProgress", () => {
);
act(() => {
- eventSourceInstances[0].simulateProgressEvent({
+ getEventSourceInstances()[0].simulateProgressEvent({
progress: 25,
message: "Starting...",
step: "started",
@@ -214,7 +142,7 @@ describe("useGenerationProgress", () => {
);
act(() => {
- eventSourceInstances[0].simulateProgressEvent({
+ getEventSourceInstances()[0].simulateProgressEvent({
progress: 100,
message: "Done!",
step: "completed",
@@ -236,7 +164,7 @@ describe("useGenerationProgress", () => {
renderHook(() => useGenerationProgress({ jobId: "test-job" }));
act(() => {
- eventSourceInstances[0].simulateProgressEvent({
+ getEventSourceInstances()[0].simulateProgressEvent({
progress: 100,
message: "Done!",
step: "completed",
@@ -244,7 +172,7 @@ describe("useGenerationProgress", () => {
});
await waitFor(() => {
- expect(eventSourceInstances[0].readyState).toBe(
+ expect(getEventSourceInstances()[0].readyState).toBe(
MockEventSource.CLOSED
);
});
@@ -263,7 +191,7 @@ describe("useGenerationProgress", () => {
);
act(() => {
- eventSourceInstances[0].simulateProgressEvent({
+ getEventSourceInstances()[0].simulateProgressEvent({
progress: 30,
message: "Error occurred",
step: "error",
@@ -288,7 +216,7 @@ describe("useGenerationProgress", () => {
);
act(() => {
- eventSourceInstances[0].simulateError();
+ getEventSourceInstances()[0].simulateError();
});
await waitFor(() => {
@@ -313,7 +241,7 @@ describe("useGenerationProgress", () => {
// Simulate an event with invalid JSON
act(() => {
- const listeners = (eventSourceInstances[0] as MockEventSource)[
+ const listeners = (getEventSourceInstances()[0] as MockEventSource)[
"listeners"
].get("progress");
if (listeners) {
@@ -339,13 +267,13 @@ describe("useGenerationProgress", () => {
useGenerationProgress({ jobId: "test-job" })
);
- expect(eventSourceInstances[0].readyState).toBe(
+ expect(getEventSourceInstances()[0].readyState).toBe(
MockEventSource.CONNECTING
);
unmount();
- expect(eventSourceInstances[0].readyState).toBe(
+ expect(getEventSourceInstances()[0].readyState).toBe(
MockEventSource.CLOSED
);
});
@@ -356,11 +284,11 @@ describe("useGenerationProgress", () => {
{ initialProps: { jobId: "test-job" as string | null } }
);
- expect(eventSourceInstances.length).toBe(1);
+ expect(getEventSourceInstances().length).toBe(1);
rerender({ jobId: null });
- expect(eventSourceInstances[0].readyState).toBe(
+ expect(getEventSourceInstances()[0].readyState).toBe(
MockEventSource.CLOSED
);
});
@@ -377,13 +305,13 @@ describe("useGenerationProgress", () => {
{ initialProps: { onProgress: onProgress1 } }
);
- expect(eventSourceInstances.length).toBe(1);
+ expect(getEventSourceInstances().length).toBe(1);
// Change callback - should NOT create new connection
rerender({ onProgress: onProgress2 });
// Still only one instance - no reconnection
- expect(eventSourceInstances.length).toBe(1);
+ expect(getEventSourceInstances().length).toBe(1);
});
it("should use latest callback even after rerender", async () => {
@@ -399,7 +327,7 @@ describe("useGenerationProgress", () => {
rerender({ onProgress: onProgress2 });
act(() => {
- eventSourceInstances[0].simulateProgressEvent({
+ getEventSourceInstances()[0].simulateProgressEvent({
progress: 50,
message: "Test",
step: "processing",
diff --git a/tests/unit/hooks/useQuizPlayer.test.ts b/tests/unit/hooks/useQuizPlayer.test.ts
new file mode 100644
index 00000000..6f1fc878
--- /dev/null
+++ b/tests/unit/hooks/useQuizPlayer.test.ts
@@ -0,0 +1,364 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, act } from "@testing-library/react";
+import { useQuizPlayer } from "@/hooks/useQuizPlayer";
+import type { QuizQuestion } from "@/lib/utils/quiz";
+
+// Mock shuffleQuiz to return deterministic order in tests
+vi.mock("@/lib/utils/quiz", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ shuffleQuiz: (questions: QuizQuestion[]) => [...questions],
+ };
+});
+
+vi.mock("@/lib/toast", () => ({
+ insightsToast: {
+ createSuccess: vi.fn(),
+ createError: vi.fn(),
+ },
+ quizToast: {
+ generateError: vi.fn(),
+ loadError: vi.fn(),
+ restartError: vi.fn(),
+ },
+}));
+
+vi.mock("@/lib/constants/mascot", () => ({
+ MASCOT_IDLE: "/mascot-idle.gif",
+ MASCOT_SUCCESS: "/mascot-success.gif",
+ MASCOT_WRONG: "/mascot-wrong.gif",
+}));
+
+function buildQuizData(): QuizQuestion[] {
+ return [
+ {
+ question: "What is 2+2?",
+ choices: ["A) 3", "B) 4", "C) 5", "D) 6"],
+ answer: "B) 4",
+ explanation: "Basic math",
+ },
+ {
+ question: "Capital of France?",
+ choices: ["A) Paris", "B) London", "C) Berlin", "D) Madrid"],
+ answer: "A) Paris",
+ explanation: "Geography",
+ },
+ {
+ question: "Largest planet?",
+ choices: ["A) Mars", "B) Venus", "C) Jupiter", "D) Saturn"],
+ answer: "C) Jupiter",
+ explanation: "Astronomy",
+ },
+ ];
+}
+
+describe("useQuizPlayer", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("initialization", () => {
+ it("starts at question 1", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+ expect(result.current.questionIndex).toBe(1);
+ expect(result.current.currentQuestion.question).toBe(
+ "What is 2+2?"
+ );
+ });
+
+ it("starts with score 0", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+ expect(result.current.score).toBe(0);
+ expect(result.current.isFinish).toBe(false);
+ expect(result.current.isAnswer).toBe(false);
+ });
+
+ it("computes total questions", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+ expect(result.current.totalQuestions).toBe(3);
+ });
+
+ it("starts with 0% progress", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+ expect(result.current.progressPercent).toBe(0);
+ });
+
+ it("shows idle mascot initially", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+ expect(result.current.mascotSrc).toBe("/mascot-idle.gif");
+ });
+ });
+
+ describe("answer submission", () => {
+ it("increments score for correct answer", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+
+ act(() => {
+ result.current.setSelectedAnswer("B) 4");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+
+ expect(result.current.score).toBe(1);
+ expect(result.current.isAnswer).toBe(true);
+ expect(result.current.userIsCorrect).toBe(true);
+ });
+
+ it("does not increment score for wrong answer", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+
+ act(() => {
+ result.current.setSelectedAnswer("A) 3");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+
+ expect(result.current.score).toBe(0);
+ expect(result.current.isAnswer).toBe(true);
+ expect(result.current.userIsCorrect).toBe(false);
+ });
+
+ it("shows success mascot for correct answer", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+
+ act(() => {
+ result.current.setSelectedAnswer("B) 4");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+
+ expect(result.current.mascotSrc).toBe("/mascot-success.gif");
+ });
+
+ it("shows wrong mascot for incorrect answer", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+
+ act(() => {
+ result.current.setSelectedAnswer("A) 3");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+
+ expect(result.current.mascotSrc).toBe("/mascot-wrong.gif");
+ });
+ });
+
+ describe("navigation", () => {
+ it("moves to next question", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+
+ act(() => {
+ result.current.setSelectedAnswer("B) 4");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+ act(() => {
+ result.current.handleNextQuestion();
+ });
+
+ expect(result.current.questionIndex).toBe(2);
+ expect(result.current.currentQuestion.question).toBe(
+ "Capital of France?"
+ );
+ expect(result.current.isAnswer).toBe(false);
+ expect(result.current.selectedAnswer).toBe("");
+ });
+
+ it("updates progress after moving to next question", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+
+ act(() => {
+ result.current.setSelectedAnswer("B) 4");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+ act(() => {
+ result.current.handleNextQuestion();
+ });
+
+ // 1 answered out of 3 = ~33.33%
+ expect(result.current.progressPercent).toBeCloseTo(33.33, 0);
+ });
+
+ it("finishes quiz after last question", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+
+ // Answer all 3 questions
+ for (let i = 0; i < 3; i++) {
+ act(() => {
+ result.current.setSelectedAnswer("A) something");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+ act(() => {
+ result.current.handleNextQuestion();
+ });
+ }
+
+ expect(result.current.isFinish).toBe(true);
+ });
+ });
+
+ describe("restart", () => {
+ it("resets all state", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: buildQuizData() })
+ );
+
+ // Play through some questions
+ act(() => {
+ result.current.setSelectedAnswer("B) 4");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+ act(() => {
+ result.current.handleNextQuestion();
+ });
+
+ act(() => {
+ result.current.restartQuiz();
+ });
+
+ expect(result.current.questionIndex).toBe(1);
+ expect(result.current.score).toBe(0);
+ expect(result.current.isFinish).toBe(false);
+ expect(result.current.isAnswer).toBe(false);
+ expect(result.current.selectedAnswer).toBe("");
+ expect(result.current.answeredQuestionsCount).toBe(0);
+ expect(result.current.progressPercent).toBe(0);
+ });
+ });
+
+ describe("insight creation", () => {
+ it("creates insight when quiz finishes with quizId and service", async () => {
+ const mockInsightsService = {
+ createInsight: vi.fn().mockResolvedValue({}),
+ getInsightsByQuizId: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useQuizPlayer({
+ quizData: buildQuizData(),
+ quizId: "quiz-123",
+ insightsService: mockInsightsService,
+ })
+ );
+
+ // Answer all questions correctly
+ for (let i = 0; i < 3; i++) {
+ const correctAnswer =
+ result.current.currentQuestion?.answer ?? "";
+ act(() => {
+ result.current.setSelectedAnswer(correctAnswer);
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+ act(() => {
+ result.current.handleNextQuestion();
+ });
+ }
+
+ // Wait for the async insight creation
+ await vi.waitFor(() => {
+ expect(mockInsightsService.createInsight).toHaveBeenCalledWith(
+ "quiz-123",
+ expect.any(Number)
+ );
+ });
+ });
+
+ it("does not create insight when quizId is missing", () => {
+ const mockInsightsService = {
+ createInsight: vi.fn().mockResolvedValue({}),
+ getInsightsByQuizId: vi.fn(),
+ };
+
+ const { result } = renderHook(() =>
+ useQuizPlayer({
+ quizData: buildQuizData(),
+ insightsService: mockInsightsService,
+ })
+ );
+
+ // Finish quiz
+ for (let i = 0; i < 3; i++) {
+ act(() => {
+ result.current.setSelectedAnswer("A) something");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+ act(() => {
+ result.current.handleNextQuestion();
+ });
+ }
+
+ expect(mockInsightsService.createInsight).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("edge cases", () => {
+ it("handles empty quiz data", () => {
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: [] })
+ );
+ expect(result.current.totalQuestions).toBe(0);
+ expect(result.current.progressPercent).toBe(0);
+ expect(result.current.currentQuestion).toBeUndefined();
+ });
+
+ it("handles single-question quiz", () => {
+ const singleQuestion = [buildQuizData()[0]];
+ const { result } = renderHook(() =>
+ useQuizPlayer({ quizData: singleQuestion })
+ );
+
+ act(() => {
+ result.current.setSelectedAnswer("B) 4");
+ });
+ act(() => {
+ result.current.handleSubmitQuestion();
+ });
+ act(() => {
+ result.current.handleNextQuestion();
+ });
+
+ expect(result.current.isFinish).toBe(true);
+ expect(result.current.score).toBe(1);
+ });
+ });
+});
diff --git a/tests/unit/hooks/useUserStatistics.test.ts b/tests/unit/hooks/useUserStatistics.test.ts
new file mode 100644
index 00000000..6765dfdf
--- /dev/null
+++ b/tests/unit/hooks/useUserStatistics.test.ts
@@ -0,0 +1,272 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { useUserStatistics } from "@/hooks/useUserStatistics";
+import type { InsightItem } from "@/lib/types/insights";
+
+function buildInsight(overrides?: Partial): InsightItem {
+ return {
+ _id: "insight-1",
+ score: 80,
+ createdAt: new Date().toISOString(),
+ author: "author-1",
+ quizId: "quiz-1",
+ userId: "user-1",
+ ...overrides,
+ };
+}
+
+function buildInsights(scores: number[], dates?: string[]): InsightItem[] {
+ return scores.map((score, i) =>
+ buildInsight({
+ _id: `insight-${i}`,
+ score,
+ createdAt: dates?.[i] ?? new Date().toISOString(),
+ })
+ );
+}
+
+describe("useUserStatistics", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-02-20T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ describe("empty insights", () => {
+ it("returns empty statistics for empty array", () => {
+ const { result } = renderHook(() => useUserStatistics([]));
+ const stats = result.current;
+
+ expect(stats.totalQuizzes).toBe(0);
+ expect(stats.averageScore).toBe(0);
+ expect(stats.bestScore).toBe(0);
+ expect(stats.worstScore).toBe(0);
+ expect(stats.trend).toBe("stable");
+ expect(stats.recentPerformance).toEqual([]);
+ expect(stats.streakData).toEqual({ current: 0, best: 0 });
+ expect(stats.weeklyActivity).toEqual([0, 0, 0, 0, 0, 0, 0]);
+ });
+ });
+
+ describe("basic statistics", () => {
+ it("computes average score correctly", () => {
+ const insights = buildInsights([60, 80, 100]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.averageScore).toBe(80);
+ });
+
+ it("rounds average score", () => {
+ const insights = buildInsights([33, 67]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.averageScore).toBe(50);
+ });
+
+ it("finds best and worst scores", () => {
+ const insights = buildInsights([40, 60, 95, 20]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.bestScore).toBe(95);
+ expect(result.current.worstScore).toBe(20);
+ });
+
+ it("counts total quizzes", () => {
+ const insights = buildInsights([50, 60, 70, 80, 90]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.totalQuizzes).toBe(5);
+ });
+ });
+
+ describe("score distribution", () => {
+ it("categorizes scores correctly", () => {
+ // excellent >= 90, good >= 70, average >= 50, poor < 50
+ const insights = buildInsights([95, 92, 75, 80, 55, 60, 30, 40]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+
+ expect(result.current.excellent).toBe(2);
+ expect(result.current.good).toBe(2);
+ expect(result.current.average).toBe(2);
+ expect(result.current.poor).toBe(2);
+ });
+
+ it("handles score at exact thresholds", () => {
+ const insights = buildInsights([90, 70, 50, 49]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+
+ expect(result.current.excellent).toBe(1); // 90
+ expect(result.current.good).toBe(1); // 70
+ expect(result.current.average).toBe(1); // 50
+ expect(result.current.poor).toBe(1); // 49
+ });
+ });
+
+ describe("trend calculation", () => {
+ it("returns stable for fewer than 4 scores", () => {
+ const insights = buildInsights([50, 60, 70]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.trend).toBe("stable");
+ });
+
+ it("detects upward trend when second half > first half + 5", () => {
+ const insights = buildInsights([30, 30, 80, 80]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.trend).toBe("up");
+ });
+
+ it("detects downward trend when second half < first half - 5", () => {
+ const insights = buildInsights([80, 80, 30, 30]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.trend).toBe("down");
+ });
+
+ it("returns stable when difference is within 5 points", () => {
+ const insights = buildInsights([50, 50, 53, 53]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.trend).toBe("stable");
+ });
+ });
+
+ describe("streak calculation", () => {
+ it("calculates current streak from the end", () => {
+ // >= 70 is a "good" score for streak
+ const insights = buildInsights([40, 80, 75, 90]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.streakData.current).toBe(3);
+ });
+
+ it("breaks current streak at first failure from end", () => {
+ const insights = buildInsights([80, 90, 40, 80]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.streakData.current).toBe(1);
+ });
+
+ it("calculates best streak across all insights", () => {
+ const insights = buildInsights([80, 90, 95, 40, 70, 80]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.streakData.best).toBe(3);
+ });
+
+ it("returns 0 streak when all scores are below threshold", () => {
+ const insights = buildInsights([30, 40, 50, 60]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.streakData.current).toBe(0);
+ expect(result.current.streakData.best).toBe(0);
+ });
+
+ it("handles score exactly at threshold (70)", () => {
+ const insights = buildInsights([70]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.streakData.current).toBe(1);
+ expect(result.current.streakData.best).toBe(1);
+ });
+ });
+
+ describe("weekly activity", () => {
+ it("counts activity for each day of the last 7 days", () => {
+ const now = new Date("2026-02-20T12:00:00Z");
+ const insights = [
+ buildInsight({
+ _id: "1",
+ createdAt: new Date("2026-02-20T10:00:00Z").toISOString(),
+ }), // today
+ buildInsight({
+ _id: "2",
+ createdAt: new Date("2026-02-20T08:00:00Z").toISOString(),
+ }), // today
+ buildInsight({
+ _id: "3",
+ createdAt: new Date("2026-02-19T10:00:00Z").toISOString(),
+ }), // yesterday
+ ];
+
+ const { result } = renderHook(() => useUserStatistics(insights));
+ const activity = result.current.weeklyActivity;
+ expect(activity).toHaveLength(7);
+ // Index 6 = today, index 5 = yesterday
+ expect(activity[6]).toBe(2);
+ expect(activity[5]).toBe(1);
+ });
+
+ it("ignores insights older than 7 days", () => {
+ const insights = [
+ buildInsight({
+ _id: "1",
+ createdAt: new Date("2026-02-10T10:00:00Z").toISOString(),
+ }),
+ ];
+
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.weeklyActivity).toEqual([
+ 0, 0, 0, 0, 0, 0, 0,
+ ]);
+ });
+ });
+
+ describe("period filtering", () => {
+ it("filters to last 7 days", () => {
+ const insights = [
+ buildInsight({
+ _id: "1",
+ score: 90,
+ createdAt: new Date("2026-02-19T10:00:00Z").toISOString(),
+ }),
+ buildInsight({
+ _id: "2",
+ score: 50,
+ createdAt: new Date("2026-01-01T10:00:00Z").toISOString(),
+ }),
+ ];
+
+ const { result } = renderHook(() =>
+ useUserStatistics(insights, "7days")
+ );
+ expect(result.current.totalQuizzes).toBe(1);
+ expect(result.current.averageScore).toBe(90);
+ });
+
+ it("filters to current year", () => {
+ const insights = [
+ buildInsight({
+ _id: "1",
+ score: 80,
+ createdAt: new Date("2026-01-15T10:00:00Z").toISOString(),
+ }),
+ buildInsight({
+ _id: "2",
+ score: 40,
+ createdAt: new Date("2025-12-31T10:00:00Z").toISOString(),
+ }),
+ ];
+
+ const { result } = renderHook(() =>
+ useUserStatistics(insights, "year")
+ );
+ expect(result.current.totalQuizzes).toBe(1);
+ expect(result.current.averageScore).toBe(80);
+ });
+
+ it("returns all insights for lifetime", () => {
+ const insights = buildInsights([60, 80]);
+ const { result } = renderHook(() =>
+ useUserStatistics(insights, "lifetime")
+ );
+ expect(result.current.totalQuizzes).toBe(2);
+ });
+ });
+
+ describe("recent performance", () => {
+ it("returns up to 10 most recent entries", () => {
+ const scores = Array.from({ length: 15 }, (_, i) => 50 + i);
+ const insights = buildInsights(scores);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.recentPerformance).toHaveLength(10);
+ });
+
+ it("returns all entries when fewer than 10", () => {
+ const insights = buildInsights([80, 90]);
+ const { result } = renderHook(() => useUserStatistics(insights));
+ expect(result.current.recentPerformance).toHaveLength(2);
+ });
+ });
+});
diff --git a/tests/unit/lib/auth-utils.test.ts b/tests/unit/lib/auth-utils.test.ts
new file mode 100644
index 00000000..e0e2b9b6
--- /dev/null
+++ b/tests/unit/lib/auth-utils.test.ts
@@ -0,0 +1,117 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ isTokenExpired,
+ getCurrentUserId,
+ isAuthenticated,
+ getAuthHeaders,
+} from "@/lib/auth-utils";
+
+vi.mock("@/lib/session", () => ({
+ sessionStorage: {
+ getToken: vi.fn(),
+ getUser: vi.fn(),
+ },
+}));
+
+import { sessionStorage } from "@/lib/session";
+
+const mockedStorage = vi.mocked(sessionStorage);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe("isTokenExpired", () => {
+ it("returns true for an expired token", () => {
+ // JWT with exp = 1000 (far in the past)
+ // Header: {"alg":"HS256","typ":"JWT"}, Payload: {"exp":1000}
+ const expiredToken =
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjEwMDB9.signature";
+ expect(isTokenExpired(expiredToken)).toBe(true);
+ });
+
+ it("returns false for a non-expired token", () => {
+ // JWT with exp = 9999999999 (far in the future)
+ const futureToken =
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTl9.signature";
+ expect(isTokenExpired(futureToken)).toBe(false);
+ });
+
+ it("returns true for an invalid token", () => {
+ expect(isTokenExpired("not-a-jwt")).toBe(true);
+ });
+
+ it("returns true for an empty string", () => {
+ expect(isTokenExpired("")).toBe(true);
+ });
+});
+
+describe("getCurrentUserId", () => {
+ it("returns id when user has id field", () => {
+ mockedStorage.getUser.mockReturnValue({ id: "user-123" });
+ expect(getCurrentUserId()).toBe("user-123");
+ });
+
+ it("returns _id when user has _id field", () => {
+ mockedStorage.getUser.mockReturnValue({ _id: "user-456" });
+ expect(getCurrentUserId()).toBe("user-456");
+ });
+
+ it("prefers id over _id", () => {
+ mockedStorage.getUser.mockReturnValue({
+ id: "user-123",
+ _id: "user-456",
+ });
+ expect(getCurrentUserId()).toBe("user-123");
+ });
+
+ it("returns null when no user", () => {
+ mockedStorage.getUser.mockReturnValue(null);
+ expect(getCurrentUserId()).toBeNull();
+ });
+
+ it("returns null when user has no id fields", () => {
+ mockedStorage.getUser.mockReturnValue({ name: "Test" });
+ expect(getCurrentUserId()).toBeNull();
+ });
+});
+
+describe("isAuthenticated", () => {
+ it("returns true when token and user exist", () => {
+ mockedStorage.getToken.mockReturnValue("valid-token");
+ mockedStorage.getUser.mockReturnValue({ id: "user-1" });
+ expect(isAuthenticated()).toBe(true);
+ });
+
+ it("returns false when token is missing", () => {
+ mockedStorage.getToken.mockReturnValue(null);
+ mockedStorage.getUser.mockReturnValue({ id: "user-1" });
+ expect(isAuthenticated()).toBe(false);
+ });
+
+ it("returns false when user is missing", () => {
+ mockedStorage.getToken.mockReturnValue("valid-token");
+ mockedStorage.getUser.mockReturnValue(null);
+ expect(isAuthenticated()).toBe(false);
+ });
+
+ it("returns false when both are missing", () => {
+ mockedStorage.getToken.mockReturnValue(null);
+ mockedStorage.getUser.mockReturnValue(null);
+ expect(isAuthenticated()).toBe(false);
+ });
+});
+
+describe("getAuthHeaders", () => {
+ it("returns Authorization header when token exists", () => {
+ mockedStorage.getToken.mockReturnValue("my-token");
+ expect(getAuthHeaders()).toEqual({
+ Authorization: "Bearer my-token",
+ });
+ });
+
+ it("returns empty object when no token", () => {
+ mockedStorage.getToken.mockReturnValue(null);
+ expect(getAuthHeaders()).toEqual({});
+ });
+});
diff --git a/tests/unit/lib/quiz.test.ts b/tests/unit/lib/quiz.test.ts
new file mode 100644
index 00000000..cf906a77
--- /dev/null
+++ b/tests/unit/lib/quiz.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { shuffleArray, shuffleQuiz, type QuizQuestion } from "@/lib/utils/quiz";
+
+function buildQuestion(overrides?: Partial): QuizQuestion {
+ return {
+ question: "What is 2 + 2?",
+ choices: ["A) 3", "B) 4", "C) 5", "D) 6"],
+ answer: "B) 4",
+ explanation: "Basic arithmetic",
+ ...overrides,
+ };
+}
+
+describe("shuffleArray", () => {
+ it("returns an array with the same length", () => {
+ const input = [1, 2, 3, 4, 5];
+ const result = shuffleArray(input);
+ expect(result).toHaveLength(input.length);
+ });
+
+ it("contains the same elements", () => {
+ const input = [1, 2, 3, 4, 5];
+ const result = shuffleArray(input);
+ expect(result.sort()).toEqual(input.sort());
+ });
+
+ it("does not mutate the original array", () => {
+ const input = [1, 2, 3, 4, 5];
+ const copy = [...input];
+ shuffleArray(input);
+ expect(input).toEqual(copy);
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(shuffleArray([])).toEqual([]);
+ });
+
+ it("returns single element for single-element array", () => {
+ expect(shuffleArray([42])).toEqual([42]);
+ });
+});
+
+describe("shuffleQuiz", () => {
+ it("preserves the correct answer text after shuffling choices", () => {
+ const question = buildQuestion({
+ choices: ["A) Paris", "B) London", "C) Berlin", "D) Madrid"],
+ answer: "A) Paris",
+ });
+
+ // Run multiple times to account for randomness
+ for (let i = 0; i < 20; i++) {
+ const [shuffled] = shuffleQuiz([question]);
+
+ // The answer should reference the correct text
+ expect(shuffled.answer).toContain("Paris");
+
+ // The answer letter should match the choice that contains "Paris"
+ const answerLetter = shuffled.answer.charAt(0);
+ const matchingChoice = shuffled.choices.find(c =>
+ c.includes("Paris")
+ );
+ expect(matchingChoice).toBeDefined();
+ expect(matchingChoice!.charAt(0)).toBe(answerLetter);
+ }
+ });
+
+ it("preserves all choice texts", () => {
+ const question = buildQuestion({
+ choices: ["A) Paris", "B) London", "C) Berlin", "D) Madrid"],
+ answer: "A) Paris",
+ });
+
+ const [shuffled] = shuffleQuiz([question]);
+ const texts = shuffled.choices.map(c => c.substring(3));
+ expect(texts.sort()).toEqual(
+ ["Berlin", "London", "Madrid", "Paris"]
+ );
+ });
+
+ it("assigns correct A/B/C/D letters to shuffled choices", () => {
+ const question = buildQuestion();
+ const [shuffled] = shuffleQuiz([question]);
+
+ shuffled.choices.forEach((choice, i) => {
+ const expectedLetter = ["A", "B", "C", "D"][i];
+ expect(choice).toMatch(new RegExp(`^${expectedLetter}\\) `));
+ });
+ });
+
+ it("returns the same number of questions", () => {
+ const questions = [buildQuestion(), buildQuestion(), buildQuestion()];
+ const result = shuffleQuiz(questions);
+ expect(result).toHaveLength(3);
+ });
+
+ it("does not mutate original questions", () => {
+ const question = buildQuestion({
+ choices: ["A) Paris", "B) London", "C) Berlin", "D) Madrid"],
+ answer: "A) Paris",
+ });
+ const originalAnswer = question.answer;
+ const originalChoices = [...question.choices];
+
+ shuffleQuiz([question]);
+
+ expect(question.answer).toBe(originalAnswer);
+ expect(question.choices).toEqual(originalChoices);
+ });
+
+ it("handles empty quiz", () => {
+ expect(shuffleQuiz([])).toEqual([]);
+ });
+});
diff --git a/tests/unit/lib/role-permissions.test.ts b/tests/unit/lib/role-permissions.test.ts
new file mode 100644
index 00000000..32a7dc11
--- /dev/null
+++ b/tests/unit/lib/role-permissions.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect } from "vitest";
+import {
+ canUserModifyTicket,
+ canUserReopenTicket,
+ USER_ROLES,
+ type UserRole,
+} from "@/hooks/useRole";
+
+describe("canUserModifyTicket", () => {
+ const userId = "user-1";
+ const otherUserId = "user-2";
+
+ it("allows admin to modify any ticket", () => {
+ expect(
+ canUserModifyTicket(USER_ROLES.ADMIN, userId, otherUserId)
+ ).toBe(true);
+ });
+
+ it("allows triage to modify any ticket", () => {
+ expect(
+ canUserModifyTicket(USER_ROLES.TRIAGE, userId, otherUserId)
+ ).toBe(true);
+ });
+
+ it("allows user to modify their own ticket", () => {
+ expect(canUserModifyTicket(USER_ROLES.USER, userId, userId)).toBe(true);
+ });
+
+ it("denies user from modifying another user's ticket", () => {
+ expect(
+ canUserModifyTicket(USER_ROLES.USER, userId, otherUserId)
+ ).toBe(false);
+ });
+
+ it("denies dev from modifying another user's ticket", () => {
+ expect(
+ canUserModifyTicket(USER_ROLES.DEV, userId, otherUserId)
+ ).toBe(false);
+ });
+
+ it("allows dev to modify their own ticket", () => {
+ expect(canUserModifyTicket(USER_ROLES.DEV, userId, userId)).toBe(true);
+ });
+});
+
+describe("canUserReopenTicket", () => {
+ const userId = "user-1";
+ const otherUserId = "user-2";
+
+ it("allows admin to reopen any ticket", () => {
+ expect(
+ canUserReopenTicket(USER_ROLES.ADMIN, userId, otherUserId)
+ ).toBe(true);
+ });
+
+ it("allows triage to reopen any ticket", () => {
+ expect(
+ canUserReopenTicket(USER_ROLES.TRIAGE, userId, otherUserId)
+ ).toBe(true);
+ });
+
+ it("allows user to reopen their own ticket", () => {
+ expect(canUserReopenTicket(USER_ROLES.USER, userId, userId)).toBe(
+ true
+ );
+ });
+
+ it("denies user from reopening another user's ticket", () => {
+ expect(
+ canUserReopenTicket(USER_ROLES.USER, userId, otherUserId)
+ ).toBe(false);
+ });
+
+ it("denies dev from reopening another user's ticket", () => {
+ expect(
+ canUserReopenTicket(USER_ROLES.DEV, userId, otherUserId)
+ ).toBe(false);
+ });
+});
diff --git a/tests/unit/lib/subscription-helpers.test.ts b/tests/unit/lib/subscription-helpers.test.ts
new file mode 100644
index 00000000..513bd812
--- /dev/null
+++ b/tests/unit/lib/subscription-helpers.test.ts
@@ -0,0 +1,129 @@
+import { describe, it, expect } from "vitest";
+import {
+ getSubscriptionStatus,
+ formatSubscriptionDate,
+} from "@/lib/subscription/subscription-helpers";
+import type { UserSubscriptionInfo } from "@/lib/subscription/subscription-types";
+
+describe("getSubscriptionStatus", () => {
+ it("returns free plan for null user", () => {
+ const status = getSubscriptionStatus(null);
+ expect(status.isPremium).toBe(false);
+ expect(status.plan).toBe("free");
+ expect(status.isActive).toBe(false);
+ expect(status.hasScheduledCancellation).toBe(false);
+ expect(status.cancellationDate).toBeNull();
+ });
+
+ it("returns free plan for undefined user", () => {
+ const status = getSubscriptionStatus(undefined);
+ expect(status.isPremium).toBe(false);
+ expect(status.plan).toBe("free");
+ });
+
+ it("returns free plan for user without accountPlan", () => {
+ const status = getSubscriptionStatus({});
+ expect(status.isPremium).toBe(false);
+ expect(status.plan).toBe("free");
+ });
+
+ it("returns premium plan for premium user", () => {
+ const user: UserSubscriptionInfo = {
+ accountPlan: "premium",
+ subscriptionStatus: "active",
+ };
+ const status = getSubscriptionStatus(user);
+ expect(status.isPremium).toBe(true);
+ expect(status.plan).toBe("premium");
+ expect(status.isActive).toBe(true);
+ });
+
+ it("returns free plan for non-premium accountPlan", () => {
+ const user: UserSubscriptionInfo = { accountPlan: "basic" };
+ const status = getSubscriptionStatus(user);
+ expect(status.isPremium).toBe(false);
+ expect(status.plan).toBe("free");
+ });
+
+ it("detects scheduled cancellation when all conditions are met", () => {
+ const user: UserSubscriptionInfo = {
+ accountPlan: "premium",
+ subscriptionStatus: "active",
+ cancelAtPeriodEnd: true,
+ currentPeriodEnd: "2026-03-01T00:00:00Z",
+ };
+ const status = getSubscriptionStatus(user);
+ expect(status.hasScheduledCancellation).toBe(true);
+ expect(status.cancellationDate).toEqual(
+ new Date("2026-03-01T00:00:00Z")
+ );
+ });
+
+ it("does not flag cancellation when cancelAtPeriodEnd is false", () => {
+ const user: UserSubscriptionInfo = {
+ accountPlan: "premium",
+ subscriptionStatus: "active",
+ cancelAtPeriodEnd: false,
+ currentPeriodEnd: "2026-03-01T00:00:00Z",
+ };
+ expect(getSubscriptionStatus(user).hasScheduledCancellation).toBe(
+ false
+ );
+ });
+
+ it("does not flag cancellation when subscription is not active", () => {
+ const user: UserSubscriptionInfo = {
+ accountPlan: "premium",
+ subscriptionStatus: "canceled",
+ cancelAtPeriodEnd: true,
+ currentPeriodEnd: "2026-03-01T00:00:00Z",
+ };
+ expect(getSubscriptionStatus(user).hasScheduledCancellation).toBe(
+ false
+ );
+ });
+
+ it("does not flag cancellation for free users", () => {
+ const user: UserSubscriptionInfo = {
+ accountPlan: "free",
+ subscriptionStatus: "active",
+ cancelAtPeriodEnd: true,
+ currentPeriodEnd: "2026-03-01T00:00:00Z",
+ };
+ expect(getSubscriptionStatus(user).hasScheduledCancellation).toBe(
+ false
+ );
+ });
+
+ it("does not flag cancellation when currentPeriodEnd is missing", () => {
+ const user: UserSubscriptionInfo = {
+ accountPlan: "premium",
+ subscriptionStatus: "active",
+ cancelAtPeriodEnd: true,
+ };
+ expect(getSubscriptionStatus(user).hasScheduledCancellation).toBe(
+ false
+ );
+ });
+
+ it("does not flag cancellation when cancelAtPeriodEnd is undefined", () => {
+ const user: UserSubscriptionInfo = {
+ accountPlan: "premium",
+ subscriptionStatus: "active",
+ currentPeriodEnd: "2026-03-01T00:00:00Z",
+ };
+ expect(getSubscriptionStatus(user).hasScheduledCancellation).toBe(
+ false
+ );
+ });
+});
+
+describe("formatSubscriptionDate", () => {
+ it("formats a date in French locale", () => {
+ const date = new Date("2026-01-20T00:00:00Z");
+ const formatted = formatSubscriptionDate(date);
+ expect(formatted).toMatch(/20/);
+ expect(formatted).toMatch(/janvier/i);
+ expect(formatted).toMatch(/2026/);
+ });
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index f43e974b..34d532c4 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -12,13 +12,20 @@ export default defineConfig({
exclude: ["**/node_modules/**", "**/e2e/**"],
coverage: {
provider: "v8",
- reporter: ["text", "json", "html"],
+ reporter: ["text", "json", "html", "json-summary"],
exclude: [
"node_modules/",
"tests/",
"**/*.d.ts",
"**/*.config.*",
"**/types/**",
+ "components/ui/**",
+ "app/**/layout.tsx",
+ "app/**/loading.tsx",
+ "app/**/error.tsx",
+ "lib/summary-sheets/**",
+ "agent-os/**",
+ ".next/**",
],
},
},