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 TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; const ADMIN_EMAIL = 'e2e-student-creation-admin@example.com'; const ADMIN_PASSWORD = 'StudentCreationTest123'; const UNIQUE_SUFFIX = Date.now(); const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); function runCommand(sql: string) { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, { encoding: 'utf-8' } ); } function clearCache() { try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pool may not exist in all environments } } function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { const output = execSync( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + `' 2>&1`, { encoding: 'utf-8' } ).trim(); const [schoolId, academicYearId] = output.split('\n'); return { schoolId, academicYearId }; } 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 }); } async function waitForStudentsPage(page: import('@playwright/test').Page) { await expect( page.getByRole('heading', { name: /gestion des élèves/i }) ).toBeVisible({ timeout: 15000 }); await expect( page.locator('.empty-state, .students-table, .alert-error') ).toBeVisible({ timeout: 15000 }); } let testClassId: string; let testClassId2: string; function createBulkStudents(count: number, tenantId: string, classId: string, academicYearId: string) { for (let i = 0; i < count; i++) { const suffix = UNIQUE_SUFFIX.toString().slice(-8); const paddedI = String(i).padStart(4, '0'); const userId = `00000000-e2e0-4000-8000-${suffix}${paddedI}`; const assignmentId = `00000001-e2e0-4000-8000-${suffix}${paddedI}`; 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 ('${userId}', '${tenantId}', NULL, 'Pagination${i}', 'Student-${UNIQUE_SUFFIX}', '[\\"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` ); runCommand( `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) VALUES ('${assignmentId}', '${tenantId}', '${userId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (id) DO NOTHING` ); } catch { // Student may already exist } } } test.describe('Student Creation & Management (Story 3.0)', () => { 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 test class for student assignment const { schoolId, academicYearId } = resolveDeterministicIds(); testClassId = `e2e-class-${UNIQUE_SUFFIX}`.substring(0, 36).padEnd(36, '0'); // Use a valid UUID format testClassId = `00000000-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`; try { runCommand( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class ${UNIQUE_SUFFIX}', 'CM2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING` ); } catch { // Class may already exist } // Create a second test class for change-class tests testClassId2 = `00000001-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`; try { runCommand( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId2}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class 2 ${UNIQUE_SUFFIX}', 'CE2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING` ); } catch { // Class may already exist } // Create 31 students for pagination tests (itemsPerPage = 30) createBulkStudents(31, TENANT_ID, testClassId, academicYearId); clearCache(); }); // ============================================================================ // Navigation // ============================================================================ test.describe('Navigation', () => { test('students page is accessible from admin nav', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await expect(page.getByRole('heading', { name: /gestion des élèves/i })).toBeVisible({ timeout: 10000 }); }); test('page title is set correctly', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await expect(page).toHaveTitle(/gestion des élèves/i); }); test('nav menu shows Élèves link', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); // Hover "Personnes" category to reveal dropdown with "Élèves" link const nav = page.locator('.desktop-nav'); await nav.getByRole('button', { name: /personnes/i }).hover(); await expect(nav.getByRole('menuitem', { name: /élèves/i })).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC1: Create Student Form // ============================================================================ test.describe('AC1 - Create Student Modal', () => { test('can open create student modal', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await expect( dialog.getByRole('heading', { name: /nouvel élève/i }) ).toBeVisible(); }); test('modal has all required fields', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Required fields await expect(dialog.locator('#student-lastname')).toBeVisible(); await expect(dialog.locator('#student-firstname')).toBeVisible(); await expect(dialog.locator('#student-class')).toBeVisible(); // Optional fields await expect(dialog.locator('#student-email')).toBeVisible(); await expect(dialog.locator('#student-dob')).toBeVisible(); await expect(dialog.locator('#student-ine')).toBeVisible(); }); test('class dropdown uses optgroup by level', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Wait for classes to load in the dropdown (optgroup elements inside select are not "visible" per Playwright) await expect(dialog.locator('#student-class optgroup')).not.toHaveCount(0, { timeout: 10000 }); // Check optgroups exist in the class select const optgroups = dialog.locator('#student-class optgroup'); const count = await optgroups.count(); expect(count).toBeGreaterThanOrEqual(1); }); test('can close modal with cancel button', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await dialog.getByRole('button', { name: /annuler/i }).click(); await expect(dialog).not.toBeVisible(); }); test('can close modal with Escape key', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Focus the modal so the keydown handler receives the event await dialog.focus(); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible(); }); }); // ============================================================================ // AC2: Create student with class assignment // ============================================================================ test.describe('AC2 - Student Creation', () => { test('can create a student without email', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Fill required fields await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`); await dialog.locator('#student-firstname').fill('Marie'); // Select first available class await dialog.locator('#student-class').selectOption({ index: 1 }); // Submit await dialog.getByRole('button', { name: /créer l'élève/i }).click(); // Success message should appear await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(/inscrit/i); // Student should appear in the list await expect(page.locator('.students-table')).toBeVisible({ timeout: 5000 }); await expect( page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }) ).toBeVisible(); }); test('can create a student with email', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await dialog.locator('#student-lastname').fill(`Martin-${UNIQUE_SUFFIX}`); await dialog.locator('#student-firstname').fill('Jean'); await dialog.locator('#student-email').fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`); await dialog.locator('#student-class').selectOption({ index: 1 }); await dialog.getByRole('button', { name: /créer l'élève/i }).click(); await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(/invitation/i); }); test('"Créer un autre" keeps modal open', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Check "Créer un autre élève" await dialog.locator('input[type="checkbox"]').check(); await dialog.locator('#student-lastname').fill(`Bernard-${UNIQUE_SUFFIX}`); await dialog.locator('#student-firstname').fill('Luc'); await dialog.locator('#student-class').selectOption({ index: 1 }); await dialog.getByRole('button', { name: /créer l'élève/i }).click(); // Success should appear but modal stays open await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(dialog).toBeVisible(); // Form fields should be cleared await expect(dialog.locator('#student-lastname')).toHaveValue(''); await expect(dialog.locator('#student-firstname')).toHaveValue(''); // Close the modal await dialog.getByRole('button', { name: /annuler/i }).click(); }); }); // ============================================================================ // AC3: Data validation // ============================================================================ test.describe('AC3 - Validation', () => { test('[P0] INE validation shows error for invalid format', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Fill required fields await dialog.locator('#student-firstname').fill('Test'); await dialog.locator('#student-lastname').fill('INEValidation'); await dialog.locator('#student-class').selectOption({ index: 1 }); // Enter invalid INE (too short) await dialog.locator('#student-ine').fill('ABC'); // Error message should appear await expect(dialog.locator('.field-error')).toBeVisible(); await expect(dialog.locator('.field-error')).toContainText(/11 caractères/i); // Submit button should be disabled await expect( dialog.getByRole('button', { name: /créer l'élève/i }) ).toBeDisabled(); // Fix INE to valid format (11 alphanumeric chars) await dialog.locator('#student-ine').fill('12345678901'); // Error should disappear await expect(dialog.locator('.field-error')).not.toBeVisible(); // Submit button should be enabled await expect( dialog.getByRole('button', { name: /créer l'élève/i }) ).toBeEnabled(); // Close modal without creating await dialog.getByRole('button', { name: /annuler/i }).click(); }); test('[P0] shows error when email is already used', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Use the same email as the student created in AC2 await dialog.locator('#student-firstname').fill('Doublon'); await dialog.locator('#student-lastname').fill('Email'); await dialog .locator('#student-email') .fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`); await dialog.locator('#student-class').selectOption({ index: 1 }); await dialog.getByRole('button', { name: /créer l'élève/i }).click(); // Error should appear (from API) await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-error')).toContainText(/email/i); // Close modal await dialog.getByRole('button', { name: /annuler/i }).click(); }); test('[P0] shows duplicate warning for same name in same class', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); await page.getByRole('button', { name: /nouvel élève/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Use same name as student created in AC2 await dialog.locator('#student-firstname').fill('Marie'); await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`); await dialog.locator('#student-class').selectOption({ index: 1 }); // Submit — should trigger duplicate check await dialog.getByRole('button', { name: /créer l'élève/i }).click(); // Duplicate warning should appear await expect(dialog.locator('.duplicate-warning')).toBeVisible({ timeout: 10000 }); await expect(dialog.locator('.duplicate-warning')).toContainText(/existe déjà/i); // Click "Annuler" — warning disappears await dialog .locator('.duplicate-warning') .getByRole('button', { name: /annuler/i }) .click(); await expect(dialog.locator('.duplicate-warning')).not.toBeVisible(); // Submit again — warning reappears await dialog.getByRole('button', { name: /créer l'élève/i }).click(); await expect(dialog.locator('.duplicate-warning')).toBeVisible({ timeout: 10000 }); // Click "Continuer" — creation succeeds despite duplicate await dialog .locator('.duplicate-warning') .getByRole('button', { name: /continuer/i }) .click(); await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC4: Students listing page // ============================================================================ test.describe('AC4 - Students List', () => { test('displays students in a table', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); // Table should have headers await expect(page.locator('.students-table th', { hasText: /nom/i })).toBeVisible(); await expect(page.locator('.students-table th', { hasText: /classe/i })).toBeVisible(); await expect(page.locator('.students-table th', { hasText: /statut/i })).toBeVisible(); }); test('search filters students by name', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); // Search for a student created earlier const searchInput = page.locator('input[type="search"]'); await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`); await page.waitForTimeout(500); await page.waitForLoadState('networkidle'); // Should find the student (use .first() because AC3 duplicate test creates a second one) await expect( page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first() ).toBeVisible({ timeout: 20000 }); }); test('rows are clickable and navigate to student detail', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); // Search for specific student const searchInput = page.locator('input[type="search"]'); await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`); await page.waitForTimeout(500); await page.waitForLoadState('networkidle'); // Click on the row (use .first() because AC3 duplicate test creates a second one) const row = page.locator('.clickable-row', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first(); await row.click(); // Should navigate to student detail page await expect(page).toHaveURL(/\/admin\/students\/[a-f0-9-]+/); await expect( page.getByRole('heading', { name: /fiche élève/i }) ).toBeVisible({ timeout: 10000 }); }); test('[P1] pagination appears when more than 30 students and navigation works', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); // Pagination nav should be visible (31 students created in beforeAll) const paginationNav = page.locator('nav[aria-label="Pagination"]'); await expect(paginationNav).toBeVisible({ timeout: 10000 }); // "Précédent" button should be disabled on page 1 await expect( paginationNav.getByRole('button', { name: /précédent/i }) ).toBeDisabled(); // Page 1 button should be active await expect( paginationNav.getByRole('button', { name: 'Page 1', exact: true }) ).toHaveAttribute('aria-current', 'page'); // Click "Suivant" to go to page 2 await paginationNav.getByRole('button', { name: /suivant/i }).click(); await page.waitForLoadState('networkidle'); // URL should contain page=2 await expect(page).toHaveURL(/page=2/); // Page 2 button should now be active await expect( paginationNav.getByRole('button', { name: 'Page 2', exact: true }) ).toHaveAttribute('aria-current', 'page'); // "Précédent" should now be enabled await expect( paginationNav.getByRole('button', { name: /précédent/i }) ).toBeEnabled(); // Table or content should still be visible (not error) await expect( page.locator('.students-table, .empty-state') ).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC5: Change student class // ============================================================================ test.describe('AC5 - Change Student Class', () => { test('[P1] can change class via modal with confirmation and optimistic update', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); // Search for a student created earlier const searchInput = page.locator('input[type="search"]'); await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`); await page.waitForTimeout(500); await page.waitForLoadState('networkidle'); // Find the student row and click "Changer de classe" (use .first() because AC3 duplicate test creates a second one) const row = page.locator('.clickable-row', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first(); await expect(row).toBeVisible({ timeout: 10000 }); await row.locator('button', { hasText: /changer de classe/i }).click(); // Change class modal should open const dialog = page.locator('[role="alertdialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); await expect( dialog.getByRole('heading', { name: /changer de classe/i }) ).toBeVisible(); // Description should mention the student name await expect(dialog.locator('#change-class-description')).toContainText( `Dupont-${UNIQUE_SUFFIX}` ); // Select a different class await dialog.locator('#change-class-select').selectOption({ index: 1 }); // Confirmation text should appear await expect(dialog.locator('.change-confirm-info')).toBeVisible(); await expect(dialog.locator('.change-confirm-info')).toContainText(/transférer/i); // Click "Confirmer le transfert" await dialog.getByRole('button', { name: /confirmer le transfert/i }).click(); // Success message should appear await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.alert-success')).toContainText(/transféré/i); // Modal should close await expect(dialog).not.toBeVisible(); }); }); // ============================================================================ // AC6: Filter by class // ============================================================================ test.describe('AC6 - Class Filter', () => { test('class filter dropdown exists with optgroups', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); const filterSelect = page.locator('#filter-class'); await expect(filterSelect).toBeVisible(); // Should have optgroups const optgroups = filterSelect.locator('optgroup'); const count = await optgroups.count(); expect(count).toBeGreaterThanOrEqual(1); }); test('[P2] selecting a class filters the student list', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/students`); await waitForStudentsPage(page); // Select a class in the filter const filterSelect = page.locator('#filter-class'); await filterSelect.selectOption({ index: 1 }); // Wait for the list to reload await page.waitForLoadState('networkidle'); // URL should contain classId parameter await expect(page).toHaveURL(/classId=/); // The page should still show the table or empty state (not an error) await expect( page.locator('.students-table, .empty-state') ).toBeVisible({ timeout: 10000 }); // Reset filter await filterSelect.selectOption({ value: '' }); await page.waitForLoadState('networkidle'); // classId should be removed from URL (polling assertion for reliability) await expect(page).not.toHaveURL(/classId=/, { timeout: 10000 }); }); }); });