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}`; const ADMIN_EMAIL = 'e2e-parent-inv-admin@example.com'; const ADMIN_PASSWORD = 'ParentInvTest123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); const UNIQUE_SUFFIX = Date.now().toString().slice(-8); const STUDENT_ID = `e2e00002-0000-4000-8000-${UNIQUE_SUFFIX}0001`; const PARENT_EMAIL = `e2e-parent-inv-${UNIQUE_SUFFIX}@test.fr`; function runCommand(sql: string) { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, { encoding: 'utf-8' } ); } 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 page.getByRole('button', { name: /se connecter/i }).click(); await page.waitForURL(/\/dashboard/, { timeout: 60000 }); } test.describe('Parent Invitations', () => { test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { // 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 a student with known name for invite tests // Note: \\" produces \" in the string, which the shell interprets as literal " inside double quotes try { runCommand( `INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${STUDENT_ID}', '${TENANT_ID}', NULL, 'Camille', 'Testinv', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING` ); } catch { /* may already exist */ } // Clean up invitations from previous runs try { runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT_ID}' OR parent_email = '${PARENT_EMAIL}'`); } catch { /* ignore */ } // Clear user cache try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`, { encoding: 'utf-8' } ); } catch { /* ignore */ } }); test('admin can navigate to parent invitations page', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); // Page should load (empty state or table) await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Title should be visible await expect(page.getByRole('heading', { name: /invitations parents/i })).toBeVisible(); }); test('admin sees empty state or data table', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); // Wait for page to load — either empty state or data table await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Verify whichever state is shown has correct content const emptyState = page.locator('.empty-state'); const dataTable = page.locator('.data-table'); const isEmptyStateVisible = await emptyState.isVisible(); if (isEmptyStateVisible) { await expect(emptyState.getByText(/aucune invitation/i)).toBeVisible(); } else { await expect(dataTable).toBeVisible(); } }); test('admin can open the invite modal', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Click "Inviter les parents" button await page.getByRole('button', { name: /inviter les parents/i }).first().click(); // Modal should appear await expect(page.locator('#invite-modal-title')).toBeVisible(); await expect(page.locator('#invite-modal-title')).toHaveText('Inviter les parents'); // Form fields should be visible await expect(page.locator('#invite-student')).toBeVisible(); await expect(page.locator('#invite-email1')).toBeVisible(); await expect(page.locator('#invite-email2')).toBeVisible(); }); test('admin can close the invite modal with Escape', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Open modal await page.getByRole('button', { name: /inviter les parents/i }).first().click(); await expect(page.locator('#invite-modal-title')).toBeVisible(); // Close with Escape await page.keyboard.press('Escape'); await expect(page.locator('#invite-modal-title')).not.toBeVisible(); }); test('send invitation requires student and email', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Open modal await page.getByRole('button', { name: /inviter les parents/i }).first().click(); await expect(page.locator('#invite-modal-title')).toBeVisible(); // Submit button should be disabled when empty const submitBtn = page.locator('.modal').getByRole('button', { name: /envoyer l'invitation/i }); await expect(submitBtn).toBeDisabled(); }); test('[P0] admin can create an invitation via modal', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Open modal await page.getByRole('button', { name: /inviter les parents/i }).first().click(); await expect(page.locator('#invite-modal-title')).toBeVisible(); // Wait for students to load in select (more than just the default empty option) await expect(page.locator('#invite-student option')).not.toHaveCount(1, { timeout: 10000 }); // Select the first available student (not the placeholder) const firstStudentOption = page.locator('#invite-student option:not([value=""])').first(); await expect(firstStudentOption).toBeAttached({ timeout: 10000 }); const studentValue = await firstStudentOption.getAttribute('value'); await page.locator('#invite-student').selectOption(studentValue!); // Fill parent email await page.locator('#invite-email1').fill(PARENT_EMAIL); // Submit button should be enabled const submitBtn = page.locator('.modal').getByRole('button', { name: /envoyer l'invitation/i }); await expect(submitBtn).toBeEnabled(); // Submit await submitBtn.click(); // Modal should close and success message should appear await expect(page.locator('#invite-modal-title')).not.toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(/invitation.*envoyée/i); }); test('[P0] invitation appears in the table after creation', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); // Wait for table to load (should no longer be empty state) await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 }); // The invitation should appear with the parent email await expect(page.locator('.data-table').getByText(PARENT_EMAIL)).toBeVisible(); // Student name should appear (any student name in the row) const invitationRow = page.locator('tr').filter({ hasText: PARENT_EMAIL }); await expect(invitationRow).toBeVisible(); // Status should be "Envoyée" await expect(page.locator('.data-table .status-badge').first()).toContainText(/envoyée/i); }); test('[P1] admin can resend an invitation', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 }); // Find the row with our invitation and click "Renvoyer" const row = page.locator('tr').filter({ hasText: PARENT_EMAIL }); await expect(row).toBeVisible(); await row.getByRole('button', { name: /renvoyer/i }).click(); // Success message should appear await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(/renvoyée/i); }); test('admin can navigate to file import page', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Click "Importer un fichier" link await page.getByRole('link', { name: /importer un fichier/i }).click(); // Should navigate to the import wizard page await expect(page).toHaveURL(/\/admin\/import\/parents/); await expect(page.getByRole('heading', { name: /import d'invitations parents/i })).toBeVisible({ timeout: 15000 }); }); test('filter by status changes the URL', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Select a status filter await page.locator('#filter-status').selectOption('sent'); await page.getByRole('button', { name: /filtrer/i }).click(); // URL should have status param await expect(page).toHaveURL(/status=sent/); }); test('reset filters clears URL params', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations?status=sent`); await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Click reset (exact match to avoid ambiguity with "Réinitialiser les filtres" in empty state) await page.getByRole('button', { name: 'Réinitialiser', exact: true }).click(); // URL should no longer contain status param await expect(page).not.toHaveURL(/status=/); }); test('[P1] filter by sent status shows the created invitation', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/parent-invitations`); await expect( page.locator('.data-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Filter by "sent" status await page.locator('#filter-status').selectOption('sent'); await page.getByRole('button', { name: /filtrer/i }).click(); // Our invitation should still be visible await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.data-table').getByText(PARENT_EMAIL)).toBeVisible(); }); test.afterAll(async () => { // Clean up invitations (by student or by email) and student try { runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT_ID}' OR parent_email = '${PARENT_EMAIL}'`); runCommand(`DELETE FROM users WHERE id = '${STUDENT_ID}'`); } catch { /* ignore */ } // Clear cache try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`, { encoding: 'utf-8' } ); } catch { /* ignore */ } }); }); test.describe('Parent Activation Page', () => { test('displays form for parent activation page', async ({ page }) => { // Navigate to the parent activation page with a dummy code await page.goto('/parent-activate/test-code-that-does-not-exist'); // Page should load await expect(page.getByRole('heading', { name: /activation.*parent/i })).toBeVisible(); // Form fields should be visible await expect(page.locator('#firstName')).toBeVisible(); await expect(page.locator('#lastName')).toBeVisible(); await expect(page.locator('#password')).toBeVisible(); await expect(page.locator('#passwordConfirmation')).toBeVisible(); }); test('validates password requirements in real-time', async ({ page }) => { await page.goto('/parent-activate/test-code'); await expect(page.locator('#password')).toBeVisible({ timeout: 5000 }); // Type a weak password await page.locator('#password').fill('abc'); // Check that requirements are shown const requirements = page.locator('.password-requirements'); await expect(requirements).toBeVisible(); // Min length should NOT be valid const minLengthItem = requirements.locator('li').filter({ hasText: /8 caractères/ }); await expect(minLengthItem).not.toHaveClass(/valid/); // Type a strong password await page.locator('#password').fill('StrongP@ss1'); // All requirements should be valid const allItems = requirements.locator('li.valid'); await expect(allItems).toHaveCount(5); }); test('validates password confirmation match', async ({ page }) => { await page.goto('/parent-activate/test-code'); await expect(page.locator('#password')).toBeVisible({ timeout: 5000 }); await page.locator('#password').fill('StrongP@ss1'); await page.locator('#passwordConfirmation').fill('DifferentPass'); // Error should show await expect(page.getByText(/ne correspondent pas/i)).toBeVisible(); }); test('submit button is disabled until form is valid', async ({ page }) => { await page.goto('/parent-activate/test-code'); await expect(page.locator('#password')).toBeVisible({ timeout: 5000 }); // Submit should be disabled initially const submitBtn = page.getByRole('button', { name: /activer mon compte/i }); await expect(submitBtn).toBeDisabled(); // Fill all fields with valid data await page.locator('#firstName').fill('Jean'); await page.locator('#lastName').fill('Parent'); await page.locator('#password').fill('StrongP@ss1'); await page.locator('#passwordConfirmation').fill('StrongP@ss1'); // Submit should be enabled await expect(submitBtn).toBeEnabled(); }); });