feat: Paralléliser les appels API de la page devoirs
Some checks failed
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

La page devoirs enchaînait séquentiellement les appels classes, subjects,
assignments et homework, produisant un waterfall de ~2.5s. En les lançant
dans un seul Promise.all, le temps de chargement correspond désormais au
plus lent des appels (~600ms) au lieu de leur somme.

Pour résoudre la dépendance de assignments sur le userId (nécessaire dans
l'URL), un nouveau helper getAuthenticatedUserId() encapsule le mécanisme
de token refresh côté module auth, évitant aux pages d'importer
refreshToken directement.

Chaque branche side-effect (loadAssignments, loadHomeworks) gère ses
erreurs via .catch() local pour éviter l'état partiel si l'une échoue
pendant que les autres réussissent.
This commit is contained in:
2026-03-16 22:36:50 +01:00
parent 68179a929f
commit a708af3a8f
4 changed files with 124 additions and 30 deletions

View File

@@ -109,13 +109,17 @@ test.describe('Homework Management (Story 5.1)', () => {
{ encoding: 'utf-8' } { encoding: 'utf-8' }
); );
// Ensure class and subject exist // Ensure classes and subject exist
const { schoolId, academicYearId } = resolveDeterministicIds(); const { schoolId, academicYearId } = resolveDeterministicIds();
try { try {
runSql( runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `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` `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( runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `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` `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) ` + `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` `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( runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `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` `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) { 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 page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); 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-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill(title); await page.locator('#hw-title').fill(title);
await page.locator('#hw-due-date').fill(getNextWeekday(5)); 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 }); 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 }) => { test('can filter homework list by class', async ({ page }) => {
await loginAsTeacher(page); await loginAsTeacher(page);
await navigateToHomework(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"]'); 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 // Reset — both visible again
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
await classFilter.selectOption({ index: 0 }); 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 });
}); });
}); });

View File

@@ -354,6 +354,18 @@ export function getCurrentUserId(): string | null {
return currentUserId; 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<string | null> {
if (!currentUserId) {
await refreshToken();
}
return currentUserId;
}
/** /**
* Register a callback to be called on logout. * Register a callback to be called on logout.
* Used to clear user-specific caches (e.g., sessions query cache). * Used to clear user-specific caches (e.g., sessions query cache).

View File

@@ -5,6 +5,7 @@ export {
authenticatedFetch, authenticatedFetch,
isAuthenticated, isAuthenticated,
getAccessToken, getAccessToken,
getAuthenticatedUserId,
getJwtRoles, getJwtRoles,
getCurrentUserId, getCurrentUserId,
type LoginCredentials, type LoginCredentials,

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config'; 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 Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@@ -121,15 +121,35 @@
return []; 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<TeacherAssignment>(data);
}
async function loadAll() { async function loadAll() {
try { try {
isLoading = true; isLoading = true;
error = null; error = null;
const apiUrl = getApiBaseUrl(); 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([ const [classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`), authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/subjects?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'); if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes');
@@ -142,19 +162,6 @@
classes = extractCollection<SchoolClass>(classesData); classes = extractCollection<SchoolClass>(classesData);
subjects = extractCollection<Subject>(subjectsData); subjects = extractCollection<Subject>(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<TeacherAssignment>(assignmentsData);
}
}
await loadHomeworks();
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue'; error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally { } finally {
@@ -884,8 +891,8 @@
{#if availableTargetClasses.length === 0} {#if availableTargetClasses.length === 0}
<p class="empty-target-classes">Aucune autre classe disponible pour cette matière.</p> <p class="empty-target-classes">Aucune autre classe disponible pour cette matière.</p>
{:else} {:else}
<div class="form-group"> <div class="form-group" role="group" aria-label="Classes cibles">
<label>Classes cibles *</label> <span class="field-label">Classes cibles *</span>
<div class="checkbox-list"> <div class="checkbox-list">
{#each availableTargetClasses as cls (cls.id)} {#each availableTargetClasses as cls (cls.id)}
{@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)} {@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)}
@@ -1279,7 +1286,8 @@
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.form-group label { .form-group label,
.field-label {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 500; font-weight: 500;