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' }
);
// 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 });
});
});