Files
Classeo/frontend/e2e/child-selector.spec.ts
Mathias STRASSER 76e16db0d8 feat: Pagination et recherche des sections admin
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient
toutes les données d'un coup, ce qui dégradait l'expérience avec un volume
croissant. La pagination côté serveur existait dans la config API Platform
mais aucun Provider ne l'exploitait.

Cette implémentation ajoute la pagination serveur (30 items/page, max 100)
avec recherche textuelle sur toutes les sections, des composants frontend
réutilisables (Pagination + SearchInput avec debounce), et la synchronisation
URL pour le partage de liens filtrés.

Les Query valident leurs paramètres (clamp page/limit, trim search) pour
éviter les abus. Les affectations utilisent des lookup maps pour résoudre
les noms sans N+1 queries. Les pages admin gèrent les race conditions
via AbortController.
2026-02-15 13:54:51 +01:00

199 lines
7.6 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const ADMIN_EMAIL = 'e2e-childselector-admin@example.com';
const ADMIN_PASSWORD = 'AdminCSTest123';
const PARENT_EMAIL = 'e2e-childselector-parent@example.com';
const PARENT_PASSWORD = 'ChildSelectorTest123';
const STUDENT1_EMAIL = 'e2e-childselector-student1@example.com';
const STUDENT1_PASSWORD = 'Student1Test123';
const STUDENT2_EMAIL = 'e2e-childselector-student2@example.com';
const STUDENT2_PASSWORD = 'Student2Test123';
let parentUserId: string;
let student1UserId: string;
let student2UserId: string;
function extractUserId(output: string): string {
const match = output.match(/User ID\s+([a-f0-9-]{36})/i);
if (!match) {
throw new Error(`Could not extract User ID from command output:\n${output}`);
}
return match[1];
}
async function loginAsAdmin(page: Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function addGuardianIfNotLinked(page: Page, studentId: string, guardianId: string, relationship: string) {
await page.goto(`${ALPHA_URL}/admin/students/${studentId}`);
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(
page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
// Skip if add button is not visible (max guardians already linked)
const addButton = page.getByRole('button', { name: /ajouter un parent/i });
if (!(await addButton.isVisible())) return;
await addButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByLabel(/id du parent/i).fill(guardianId);
await dialog.getByLabel(/type de relation/i).selectOption(relationship);
await dialog.getByRole('button', { name: 'Ajouter' }).click();
// Wait for either success (new link) or error (already linked → 409)
await expect(
page.locator('.alert-success').or(page.locator('.alert-error'))
).toBeVisible({ timeout: 10000 });
}
async function removeFirstGuardian(page: Page, studentId: string) {
await page.goto(`${ALPHA_URL}/admin/students/${studentId}`);
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(
page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
// Skip if no guardian to remove
if (!(await page.locator('.guardian-item').first().isVisible())) return;
const guardianItem = page.locator('.guardian-item').first();
await guardianItem.getByRole('button', { name: /retirer/i }).click();
await expect(guardianItem.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
await guardianItem.getByRole('button', { name: /oui/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
}
test.describe('Child Selector', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Create parent user
const parentOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`,
{ encoding: 'utf-8' }
);
parentUserId = extractUserId(parentOutput);
// Create student 1
const student1Output = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT1_EMAIL} --password=${STUDENT1_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
student1UserId = extractUserId(student1Output);
// Create student 2
const student2Output = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
student2UserId = extractUserId(student2Output);
// Use admin UI to link parent to both students
const page = await browser.newPage();
await loginAsAdmin(page);
await addGuardianIfNotLinked(page, student1UserId, parentUserId, 'tuteur');
await addGuardianIfNotLinked(page, student2UserId, parentUserId, 'tutrice');
await page.close();
});
async function loginAsParent(page: Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
test('[P1] parent with multiple children should see child selector', async ({ page }) => {
await loginAsParent(page);
// ChildSelector should be visible when parent has 2+ children
const childSelector = page.locator('.child-selector');
await expect(childSelector).toBeVisible({ timeout: 10000 });
// Should display the label
await expect(childSelector.locator('.child-selector-label')).toHaveText('Enfant :');
// Should have 2 child buttons
const buttons = childSelector.locator('.child-button');
await expect(buttons).toHaveCount(2);
// First child should be auto-selected
await expect(buttons.first()).toHaveClass(/selected/);
});
test('[P1] parent can switch between children', async ({ page }) => {
await loginAsParent(page);
const childSelector = page.locator('.child-selector');
await expect(childSelector).toBeVisible({ timeout: 10000 });
const buttons = childSelector.locator('.child-button');
await expect(buttons).toHaveCount(2);
// First button should be selected initially
await expect(buttons.first()).toHaveClass(/selected/);
await expect(buttons.nth(1)).not.toHaveClass(/selected/);
// Click second button
await buttons.nth(1).click();
// Second button should now be selected, first should not
await expect(buttons.nth(1)).toHaveClass(/selected/);
await expect(buttons.first()).not.toHaveClass(/selected/);
});
test('[P1] parent with single child should see static child name', async ({ browser, page }) => {
// Remove one link via admin UI
const adminPage = await browser.newPage();
await loginAsAdmin(adminPage);
await removeFirstGuardian(adminPage, student2UserId);
await adminPage.close();
await loginAsParent(page);
// ChildSelector should be visible with 1 child (showing name, no buttons)
await expect(page.locator('.child-selector')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.child-button')).toHaveCount(0);
// Restore the second link via admin UI for clean state
const restorePage = await browser.newPage();
await loginAsAdmin(restorePage);
await addGuardianIfNotLinked(restorePage, student2UserId, parentUserId, 'tutrice');
await restorePage.close();
});
});