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); 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-guardian-admin@example.com'; const ADMIN_PASSWORD = 'GuardianTest123'; const STUDENT_EMAIL = 'e2e-guardian-student@example.com'; const STUDENT_PASSWORD = 'StudentTest123'; const PARENT_EMAIL = 'e2e-guardian-parent@example.com'; const PARENT_PASSWORD = 'ParentTest123'; const PARENT_FIRST_NAME = 'GuardParent'; const PARENT_LAST_NAME = 'TestOne'; const PARENT2_EMAIL = 'e2e-guardian-parent2@example.com'; const PARENT2_PASSWORD = 'Parent2Test123'; const PARENT2_FIRST_NAME = 'GuardParent'; const PARENT2_LAST_NAME = 'TestTwo'; 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('Guardian 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 first 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' } ); // Create second parent user for the max guardians test execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT2_EMAIL} --password=${PARENT2_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT2_FIRST_NAME} --lastName=${PARENT2_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 } }); 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: 60000 }), 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: 10000 }); await expect( page.getByText(/aucun parent\/tuteur lié/i) .or(page.locator('.guardian-list')) ).toBeVisible({ timeout: 10000 }); } /** * Opens the add-guardian dialog, searches for a parent by name, * selects them from results, and submits. * Waits for the success message before returning. */ async function addGuardianViaDialog( page: import('@playwright/test').Page, parentSearchTerm: string, relationshipType: string ) { await page.getByRole('button', { name: /ajouter un parent/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Type in search field and wait for results const searchInput = dialog.getByRole('combobox', { name: /rechercher/i }); await searchInput.fill(parentSearchTerm); // 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(); // Verify a parent was selected await expect(dialog.getByText(/sélectionné/i)).toBeVisible(); await dialog.getByLabel(/type de relation/i).selectOption(relationshipType); await dialog.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); } /** * Removes the first guardian in the list using the two-step confirmation. * Waits for the success message before returning. */ async function removeFirstGuardian(page: import('@playwright/test').Page) { const guardianItem = page.locator('.guardian-item').first(); await expect(guardianItem).toBeVisible(); 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('[P1] should display empty guardian list for student with no guardians', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); await waitForGuardianSection(page); // Should show the empty state since no guardians are linked await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible(); // The "add guardian" button should be visible await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible(); }); test('[P1] should link a guardian to a student', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); await waitForGuardianSection(page); // Add the guardian via the dialog (search by email) await addGuardianViaDialog(page, PARENT_EMAIL, 'père'); // Verify success message 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('[P1] should not show already-linked parent in search results', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); await waitForGuardianSection(page); // The parent from the previous test is still linked await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 }); // Open the add-guardian dialog and search for the already-linked parent 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); // The listbox should show "Aucun parent trouvé" since the parent is excluded const listbox = dialog.locator('#parent-search-listbox'); await expect(listbox).toBeVisible({ timeout: 10000 }); await expect(listbox.getByText(/aucun parent trouvé/i)).toBeVisible(); // No option should be selectable await expect(listbox.locator('[role="option"]')).toHaveCount(0); // The guardian count should still be 1 await dialog.locator('.modal-close').click(); await expect(page.locator('.guardian-item')).toHaveCount(1); }); test('[P1] should 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 }); // Remove the first guardian using the two-step confirmation await removeFirstGuardian(page); // Verify success message await expect(page.locator('.alert-success')).toContainText(/liaison supprimée/i); // The empty state should return since the only guardian was removed await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 }); }); test('[P2] should not show add button when maximum guardians reached', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); await waitForGuardianSection(page); // Link first guardian (père) — search by email await addGuardianViaDialog(page, PARENT_EMAIL, 'père'); // Wait for the add button to still be available after first link await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible({ timeout: 5000 }); // Link second guardian (mère) — search by email await addGuardianViaDialog(page, PARENT2_EMAIL, 'mère'); // Now with 2 guardians linked, the add button should NOT be visible await expect(page.getByRole('button', { name: /ajouter un parent/i })).not.toBeVisible({ timeout: 5000 }); // Verify both guardian items are displayed await expect(page.locator('.guardian-item')).toHaveCount(2); // Clean up: remove both guardians so the state is clean for potential re-runs await removeFirstGuardian(page); await expect(page.locator('.guardian-item')).toHaveCount(1, { timeout: 5000 }); await removeFirstGuardian(page); // Verify empty state returns await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 }); }); test('[P1] should clear search field when reopening add dialog after successful link', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`); await waitForGuardianSection(page); // Link a guardian via dialog (this fills the search, selects, and submits) await addGuardianViaDialog(page, PARENT_EMAIL, 'père'); // Reopen the add-guardian dialog await page.getByRole('button', { name: /ajouter un parent/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // The search field should be empty (reset after successful link) await expect(dialog.getByRole('combobox', { name: /rechercher/i })).toHaveValue(''); // No parent should be marked as selected await expect(dialog.getByText(/sélectionné/i)).not.toBeVisible(); // Close modal and clean up await dialog.locator('.modal-close').click(); await removeFirstGuardian(page); await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 }); }); });