Files
Classeo/frontend/e2e/students.spec.ts
Mathias STRASSER 8f83dafb7a feat: Remplacer le champ UUID par une recherche autocomplete pour la liaison parent-élève
L'ajout d'un parent à un élève nécessitait de connaître et coller
manuellement l'UUID du compte parent, ce qui était source d'erreurs
et très peu ergonomique pour les administrateurs.

Le nouveau composant ParentSearchInput offre une recherche par nom/email
avec autocomplétion (debounce 300ms, navigation clavier, ARIA combobox).
Les parents déjà liés sont exclus des résultats, et la sélection se
réinitialise proprement quand l'admin retape dans le champ.
2026-03-12 00:41:41 +01:00

429 lines
17 KiB
TypeScript

import { test, expect } 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);
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
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}`;
// Test credentials
const ADMIN_EMAIL = 'e2e-students-admin@example.com';
const ADMIN_PASSWORD = 'StudentsTest123';
const STUDENT_EMAIL = 'e2e-students-eleve@example.com';
const STUDENT_PASSWORD = 'StudentTest123';
const PARENT_EMAIL = 'e2e-students-parent@example.com';
const PARENT_PASSWORD = 'ParentTest123';
const PARENT_FIRST_NAME = 'StudParent';
const PARENT_LAST_NAME = 'TestLink';
let studentUserId: string;
/**
* Extracts the User ID from the Symfony console table output.
*
* The create-test-user command outputs a table like:
* | Property | Value |
* | User ID | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
*/
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];
}
test.describe('Student Management', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
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 student user and capture userId
const studentOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
studentUserId = extractUserId(studentOutput);
// Create parent user (with name for search-based linking)
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 --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up any existing guardian links for this student (DB + cache)
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM student_guardians WHERE student_id = '${studentUserId}'" 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear student_guardians.cache --env=dev 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Ignore cleanup errors -- table may not have data yet
}
});
// Helper to login as admin
async function loginAsAdmin(page: import('@playwright/test').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()
]);
}
/**
* Waits for the guardian section to be fully hydrated (client-side JS loaded).
*
* The server renders the section with a "Chargement..." indicator. Only after
* client-side hydration does the $effect() fire, triggering loadGuardians().
* When that completes, either the empty-state or the guardian-list appears.
* Waiting for one of these ensures the component is interactive.
*/
async function waitForGuardianSection(page: import('@playwright/test').Page) {
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 15000 });
await expect(
page.getByText(/aucun parent\/tuteur lié/i)
.or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 15000 });
}
// ============================================================================
// Student Detail Page - Navigation
// ============================================================================
test.describe('Navigation', () => {
test('can access student detail page via direct URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
// Page should load with the student detail heading
await expect(page.getByRole('heading', { name: /fiche élève/i })).toBeVisible({ timeout: 10000 });
});
test('page title is set correctly', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await expect(page).toHaveTitle(/fiche élève/i);
});
test('back link navigates to students page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
// Wait for page to be fully loaded
await expect(page.getByRole('heading', { name: /fiche élève/i })).toBeVisible({ timeout: 10000 });
// Click the back link
await page.locator('.back-link').click();
// Should navigate to students page
await expect(page).toHaveURL(/\/admin\/students/);
});
});
// ============================================================================
// Student Detail Page - Guardian Section
// ============================================================================
test.describe('Guardian Section', () => {
test('shows empty guardian list for student with no guardians', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Clean up guardians if any exist (cross-browser interference: parallel
// browser projects share the same DB, so another browser may have added
// guardians between our beforeAll cleanup and this test).
while (await page.locator('.guardian-item').count() > 0) {
const item = page.locator('.guardian-item').first();
await item.getByRole('button', { name: /retirer/i }).click();
await expect(item.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
await item.getByRole('button', { name: /oui/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(500);
}
// Should show the empty state
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 15000 });
// The "add guardian" button should be visible
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible();
});
test('displays the guardian section header', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Section title should be visible
await expect(page.getByRole('heading', { name: /parents \/ tuteurs/i })).toBeVisible();
});
test('can open add guardian modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Click the add guardian button
await page.getByRole('button', { name: /ajouter un parent/i }).click();
// Modal should appear
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Modal should have the correct heading
await expect(dialog.getByRole('heading', { name: /ajouter un parent\/tuteur/i })).toBeVisible();
// Form fields should be present
await expect(dialog.getByRole('combobox', { name: /rechercher/i })).toBeVisible();
await expect(dialog.getByLabel(/type de relation/i)).toBeVisible();
});
test('can cancel adding a guardian', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Open the modal
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Click cancel
await dialog.getByRole('button', { name: /annuler/i }).click();
// Modal should close
await expect(dialog).not.toBeVisible();
// Empty state should remain
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible();
});
test('can link a guardian to a student', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Open the add guardian modal
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Search for the parent by name
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(PARENT_EMAIL);
// Wait for autocomplete results
const listbox = dialog.locator('#parent-search-listbox');
await expect(listbox).toBeVisible({ timeout: 10000 });
const option = listbox.locator('[role="option"]').first();
await option.click();
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
await dialog.getByLabel(/type de relation/i).selectOption('père');
// Submit
await dialog.getByRole('button', { name: 'Ajouter' }).click();
// Success message should appear
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/parent ajouté/i);
// The guardian list should now contain the new item
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
const guardianItem = page.locator('.guardian-item').first();
await expect(guardianItem).toBeVisible();
await expect(guardianItem).toContainText('Père');
// Empty state should no longer be visible
await expect(page.getByText(/aucun parent\/tuteur lié/i)).not.toBeVisible();
});
test('can unlink a guardian from a student', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Wait for the guardian list to be loaded (from previous test)
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
// Click remove on the first guardian
const guardianItem = page.locator('.guardian-item').first();
await expect(guardianItem).toBeVisible();
await guardianItem.getByRole('button', { name: /retirer/i }).click();
// Two-step confirmation should appear
await expect(guardianItem.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
await guardianItem.getByRole('button', { name: /oui/i }).click();
// Success message should appear
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/liaison supprimée/i);
// The empty state should return
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
});
test('can cancel guardian removal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// First, add a guardian to have something to remove
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(PARENT_EMAIL);
const listbox2 = dialog.locator('#parent-search-listbox');
await expect(listbox2).toBeVisible({ timeout: 10000 });
const option = listbox2.locator('[role="option"]').first();
await option.click();
await dialog.getByLabel(/type de relation/i).selectOption('mère');
await dialog.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
// Now try to remove but cancel
const guardianItem = page.locator('.guardian-item').first();
await expect(guardianItem).toBeVisible();
await guardianItem.getByRole('button', { name: /retirer/i }).click();
// Confirmation should appear
await expect(guardianItem.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
// Cancel the removal
await guardianItem.getByRole('button', { name: /non/i }).click();
// Guardian should still be in the list
await expect(page.locator('.guardian-item')).toHaveCount(1);
});
test('relationship type options are available in the modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Open the add guardian modal
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Verify all relationship options are available
const select = dialog.getByLabel(/type de relation/i);
const options = select.locator('option');
// Count options (should include: père, mère, tuteur, tutrice, grand-père, grand-mère, autre)
const count = await options.count();
expect(count).toBeGreaterThanOrEqual(7);
// Verify some specific options exist (use exact match to avoid substring matches like Grand-père)
await expect(options.filter({ hasText: /^Père$/ })).toHaveCount(1);
await expect(options.filter({ hasText: /^Mère$/ })).toHaveCount(1);
await expect(options.filter({ hasText: /^Tuteur$/ })).toHaveCount(1);
// Close modal
await dialog.getByRole('button', { name: /annuler/i }).click();
});
});
// ============================================================================
// Student Detail Page - Access from Users Page
// ============================================================================
test.describe('Access from Users Page', () => {
test('users page lists the student user', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
// Wait for users table to load
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Search for the student user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(STUDENT_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// The student email should appear in the users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
await expect(studentRow).toBeVisible();
});
test('users table shows student role', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
// Wait for users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the student user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(STUDENT_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the student row and verify role
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
await expect(studentRow).toContainText(/élève/i);
});
});
// ============================================================================
// Cleanup - remove guardian links after all tests
// ============================================================================
test('cleanup: remove remaining guardian links', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Remove all remaining guardians
while (await page.locator('.guardian-item').count() > 0) {
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 });
// Wait for the list to update
await page.waitForTimeout(500);
}
// Verify empty state
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
});
});