diff --git a/frontend/e2e/homework.spec.ts b/frontend/e2e/homework.spec.ts index 400b1d5..92be8eb 100644 --- a/frontend/e2e/homework.spec.ts +++ b/frontend/e2e/homework.spec.ts @@ -109,13 +109,17 @@ test.describe('Homework Management (Story 5.1)', () => { { encoding: 'utf-8' } ); - // Ensure class and subject exist + // Ensure classes and subject exist const { schoolId, academicYearId } = resolveDeterministicIds(); try { runSql( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` @@ -143,6 +147,10 @@ test.describe('Homework Management (Story 5.1)', () => { `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); + runSql( + `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` + ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` @@ -422,9 +430,17 @@ test.describe('Homework Management (Story 5.1)', () => { }); async function createHomework(page: import('@playwright/test').Page, title: string) { + await createHomeworkInClass(page, title, { index: 1 }); + } + + async function createHomeworkInClass( + page: import('@playwright/test').Page, + title: string, + classOption: { index: number } | { label: string }, + ) { await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); - await page.locator('#hw-class').selectOption({ index: 1 }); + await page.locator('#hw-class').selectOption(classOption); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill(title); await page.locator('#hw-due-date').fill(getNextWeekday(5)); @@ -576,24 +592,81 @@ test.describe('Homework Management (Story 5.1)', () => { await expect(page.getByText('Devoir date custom')).toHaveCount(2, { timeout: 10000 }); }); + test('shows validation errors per class when duplication rules fail', async ({ page }) => { + await loginAsTeacher(page); + await navigateToHomework(page); + await createHomework(page, 'Devoir validation règles'); + + await page.getByRole('button', { name: /dupliquer/i }).first().click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + + // Select first target class + const firstCheckbox = dialog.locator('input[type="checkbox"]').first(); + await firstCheckbox.check(); + + // Intercept the POST duplicate API call to simulate a validation error + await page.route( + '**/api/homework/*/duplicate', + async (route, request) => { + // Extract targetClassIds from the request body + const body = request.postDataJSON(); + const targetClassIds: string[] = body.targetClassIds ?? []; + + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ + error: 'Validation échouée pour certaines classes.', + results: targetClassIds.map((classId: string) => ({ + classId, + valid: false, + error: "L'enseignant n'est pas affecté à cette classe pour cette matière.", + })), + }), + }); + }, + ); + + // Click duplicate + await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click(); + + // Verify: error alert appears in the modal + await expect(dialog.locator('.alert-error')).toBeVisible({ timeout: 10000 }); + await expect(dialog.getByText(/certaines classes ne passent pas la validation/i)).toBeVisible(); + + // Verify: validation error message appears next to the class + await expect(dialog.locator('.validation-error')).toBeVisible(); + await expect( + dialog.getByText(/l'enseignant n'est pas affecté à cette classe/i), + ).toBeVisible(); + + // Verify: modal stays open (not closed on validation error) + await expect(dialog).toBeVisible(); + }); + test('can filter homework list by class', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); - await createHomework(page, 'Devoir filtre classe'); - // Class filter should be visible + // Create homework in two different classes + await createHomeworkInClass(page, 'Devoir classe A', { label: 'E2E-HW-6A' }); + await createHomeworkInClass(page, 'Devoir classe B', { label: 'E2E-HW-6B' }); + + // Both visible initially + await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir classe B')).toBeVisible({ timeout: 5000 }); + + // Filter by class A — class B homework must disappear const classFilter = page.locator('select[aria-label="Filtrer par classe"]'); - await expect(classFilter).toBeVisible(); + await classFilter.selectOption({ label: 'E2E-HW-6A' }); + await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir classe B')).not.toBeVisible({ timeout: 5000 }); - // Select a class to filter - await classFilter.selectOption({ index: 1 }); - - // Should still show homework (it matches the filter) - await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 }); - - // Select "Toutes les classes" to reset + // Reset — both visible again await classFilter.selectOption({ index: 0 }); - await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('Devoir classe B')).toBeVisible({ timeout: 5000 }); }); }); diff --git a/frontend/src/lib/auth/auth.svelte.ts b/frontend/src/lib/auth/auth.svelte.ts index 16e2314..18b5020 100644 --- a/frontend/src/lib/auth/auth.svelte.ts +++ b/frontend/src/lib/auth/auth.svelte.ts @@ -354,6 +354,18 @@ export function getCurrentUserId(): string | null { return currentUserId; } +/** + * Retourne l'ID utilisateur après avoir garanti que le token est rafraîchi. + * Encapsule le mécanisme de refresh pour que les appelants n'aient pas + * à manipuler refreshToken() directement. + */ +export async function getAuthenticatedUserId(): Promise { + if (!currentUserId) { + await refreshToken(); + } + return currentUserId; +} + /** * Register a callback to be called on logout. * Used to clear user-specific caches (e.g., sessions query cache). diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts index 8f53137..1bbaee1 100644 --- a/frontend/src/lib/auth/index.ts +++ b/frontend/src/lib/auth/index.ts @@ -5,6 +5,7 @@ export { authenticatedFetch, isAuthenticated, getAccessToken, + getAuthenticatedUserId, getJwtRoles, getCurrentUserId, type LoginCredentials, diff --git a/frontend/src/routes/dashboard/teacher/homework/+page.svelte b/frontend/src/routes/dashboard/teacher/homework/+page.svelte index d55e2fd..8c3c922 100644 --- a/frontend/src/routes/dashboard/teacher/homework/+page.svelte +++ b/frontend/src/routes/dashboard/teacher/homework/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; import { getApiBaseUrl } from '$lib/api/config'; - import { authenticatedFetch, getCurrentUserId } from '$lib/auth'; + import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth'; import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; import { untrack } from 'svelte'; @@ -121,15 +121,35 @@ return []; } + async function loadAssignments() { + const userId = await getAuthenticatedUserId(); + if (!userId) return; + const apiUrl = getApiBaseUrl(); + const res = await authenticatedFetch(`${apiUrl}/teachers/${userId}/assignments`); + if (!res.ok) throw new Error('Erreur lors du chargement des affectations'); + const data = await res.json(); + assignments = extractCollection(data); + } + async function loadAll() { try { isLoading = true; error = null; const apiUrl = getApiBaseUrl(); + // classesRes/subjectsRes are used below; loadAssignments & loadHomeworks + // apply side-effects internally and their results are intentionally ignored const [classesRes, subjectsRes] = await Promise.all([ authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`), authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`), + loadAssignments().catch((e) => { + error = e instanceof Error ? e.message : 'Erreur lors du chargement des affectations'; + }), + loadHomeworks().catch((e) => { + homeworks = []; + totalItems = 0; + error = e instanceof Error ? e.message : 'Erreur inconnue'; + }), ]); if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes'); @@ -142,19 +162,6 @@ classes = extractCollection(classesData); subjects = extractCollection(subjectsData); - - // getCurrentUserId() must be called AFTER authenticatedFetch, - // which triggers token refresh and sets currentUserId in memory - const userId = getCurrentUserId(); - if (userId) { - const assignmentsRes = await authenticatedFetch(`${apiUrl}/teachers/${userId}/assignments`); - if (assignmentsRes.ok) { - const assignmentsData = await assignmentsRes.json(); - assignments = extractCollection(assignmentsData); - } - } - - await loadHomeworks(); } catch (e) { error = e instanceof Error ? e.message : 'Erreur inconnue'; } finally { @@ -884,8 +891,8 @@ {#if availableTargetClasses.length === 0}

Aucune autre classe disponible pour cette matière.

{:else} -
- +
+ Classes cibles *
{#each availableTargetClasses as cls (cls.id)} {@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)} @@ -1279,7 +1286,8 @@ margin-bottom: 1.25rem; } - .form-group label { + .form-group label, + .field-label { display: block; margin-bottom: 0.5rem; font-weight: 500;