import { test, expect } from '@playwright/test'; import { execSync } from 'child_process'; import { join, dirname } from 'path'; import { writeFileSync, mkdirSync, unlinkSync } from 'fs'; 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-import-admin@example.com'; const ADMIN_PASSWORD = 'ParentImportTest123'; 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); // Student IDs — deterministic UUIDs for cleanup const STUDENT1_ID = `e2e00001-0000-4000-8000-${UNIQUE_SUFFIX}0001`; const STUDENT2_ID = `e2e00001-0000-4000-8000-${UNIQUE_SUFFIX}0002`; // Unique student names to avoid collision with existing data const STUDENT1_FIRST = `Alice${UNIQUE_SUFFIX}`; const STUDENT1_LAST = `Dupont${UNIQUE_SUFFIX}`; const STUDENT2_FIRST = `Bob${UNIQUE_SUFFIX}`; const STUDENT2_LAST = `Martin${UNIQUE_SUFFIX}`; 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 Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } function createCsvFixture(filename: string, content: string): string { const tmpDir = join(__dirname, 'fixtures'); mkdirSync(tmpDir, { recursive: true }); const filePath = join(tmpDir, filename); writeFileSync(filePath, content, 'utf-8'); return filePath; } test.describe('Parent Invitation Import via CSV', () => { 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 2 students with unique names for matching // 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 ('${STUDENT1_ID}', '${TENANT_ID}', NULL, '${STUDENT1_FIRST}', '${STUDENT1_LAST}', '[\\"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 */ } 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 ('${STUDENT2_ID}', '${TENANT_ID}', NULL, '${STUDENT2_FIRST}', '${STUDENT2_LAST}', '[\\"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 */ } // Clear user cache to ensure students are visible 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('displays the import wizard page', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); await expect(page.getByRole('heading', { name: /import d'invitations parents/i })).toBeVisible({ timeout: 15000 }); // Verify stepper is visible with 4 steps await expect(page.locator('.stepper .step')).toHaveCount(4); // Verify dropzone is visible await expect(page.locator('.dropzone')).toBeVisible(); await expect(page.getByText(/glissez votre fichier/i)).toBeVisible(); }); test('uploads a CSV file and shows mapping step', async ({ page }) => { const csvContent = `Nom élève;Email parent 1;Email parent 2\n${STUDENT1_LAST} ${STUDENT1_FIRST};parent1@test.fr;parent2@test.fr\n`; const csvPath = createCsvFixture('e2e-parent-import-test.csv', csvContent); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles(csvPath); // Should transition to mapping step await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); // File info should be visible await expect(page.getByText(/e2e-parent-import-test\.csv/i)).toBeVisible(); await expect(page.getByText(/1 lignes/i)).toBeVisible(); // Column names should appear in mapping await expect(page.locator('.column-name').filter({ hasText: /^Nom élève$/ })).toBeVisible(); await expect(page.locator('.column-name').filter({ hasText: /^Email parent 1$/ })).toBeVisible(); await expect(page.locator('.column-name').filter({ hasText: /^Email parent 2$/ })).toBeVisible(); try { unlinkSync(csvPath); } catch { /* ignore */ } }); test('validates required fields in mapping', async ({ page }) => { const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};alice@test.fr\n`; const csvPath = createCsvFixture('e2e-parent-import-required.csv', csvContent); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles(csvPath); await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); // The mapping should be auto-suggested, so the "Valider le mapping" button should be enabled const validateButton = page.getByRole('button', { name: /valider le mapping/i }); await expect(validateButton).toBeVisible(); await expect(validateButton).toBeEnabled(); try { unlinkSync(csvPath); } catch { /* ignore */ } }); test('navigates back from mapping to upload', async ({ page }) => { const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};alice@test.fr\n`; const csvPath = createCsvFixture('e2e-parent-import-back.csv', csvContent); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles(csvPath); await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); // Click back button await page.getByRole('button', { name: /retour/i }).click(); // Should be back on upload step await expect(page.locator('.dropzone')).toBeVisible({ timeout: 10000 }); try { unlinkSync(csvPath); } catch { /* ignore */ } }); test('rejects non-CSV files', async ({ page }) => { const pdfPath = createCsvFixture('e2e-parent-import-bad.pdf', 'not a csv file'); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles(pdfPath); // Should show error await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); try { unlinkSync(pdfPath); } catch { /* ignore */ } }); test('shows preview step with valid/error counts', async ({ page }) => { // Use only one row with a clearly non-existent student to verify error display const csvContent = 'Nom élève;Email parent 1\nZzznotfound99 Xxxxnomatch88;parent.err@test.fr\n'; const csvPath = createCsvFixture('e2e-parent-import-preview.csv', csvContent); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); const fileInput = page.locator('input[type="file"]'); await fileInput.setInputFiles(csvPath); // Wait for mapping step await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); // Submit mapping await page.getByRole('button', { name: /valider le mapping/i }).click(); // Wait for preview step await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); // Should show 0 valid and 1 error await expect(page.locator('.summary-card.valid')).toBeVisible(); await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0'); await expect(page.locator('.summary-card.error')).toBeVisible(); await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1'); // Error detail should mention the unknown student await expect(page.locator('.error-detail').first()).toContainText(/non trouvé/i); // Send button should be disabled (no valid rows) await expect(page.getByRole('button', { name: /envoyer/i })).toBeDisabled(); try { unlinkSync(csvPath); } catch { /* ignore */ } }); test('[P0] completes full import flow', async ({ page }) => { const email1 = `parent.import.${UNIQUE_SUFFIX}@test.fr`; const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};${email1}\n`; const csvPath = createCsvFixture('e2e-parent-import-full-flow.csv', csvContent); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); // Step 1: Upload await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); await page.locator('input[type="file"]').setInputFiles(csvPath); // Step 2: Mapping await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); await page.getByRole('button', { name: /valider le mapping/i }).click(); // Step 3: Preview await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('1'); await page.getByRole('button', { name: /envoyer 1 invitation/i }).click(); // Step 4: Result await expect(page.getByRole('heading', { name: /invitations envoyées/i })).toBeVisible({ timeout: 30000 }); // Verify report stats const stats = page.locator('.report-stats .stat'); const sentStat = stats.filter({ hasText: /envoyées/ }); await expect(sentStat.locator('.stat-value')).toHaveText('1'); const errorStat = stats.filter({ hasText: /erreurs/ }); await expect(errorStat.locator('.stat-value')).toHaveText('0'); // Verify action buttons await expect(page.getByRole('button', { name: /voir les invitations/i })).toBeVisible(); try { unlinkSync(csvPath); } catch { /* ignore */ } // Cleanup: remove the created invitation try { runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT1_ID}' AND parent_email = '${email1}'`); } catch { /* ignore */ } }); test('[P1] handles multiple emails per student', async ({ page }) => { const email1 = `parent1.multi.${UNIQUE_SUFFIX}@test.fr`; const email2 = `parent2.multi.${UNIQUE_SUFFIX}@test.fr`; const csvContent = `Nom élève;Email parent 1;Email parent 2\n${STUDENT2_LAST} ${STUDENT2_FIRST};${email1};${email2}\n`; const csvPath = createCsvFixture('e2e-parent-import-multi-email.csv', csvContent); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); // Upload await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); await page.locator('input[type="file"]').setInputFiles(csvPath); // Mapping await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); await page.getByRole('button', { name: /valider le mapping/i }).click(); // Preview — should show 1 valid row await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('1'); // Send — 2 invitations (one per email) await page.getByRole('button', { name: /envoyer 1 invitation/i }).click(); // Result await expect(page.locator('.report-stats')).toBeVisible({ timeout: 30000 }); // Should have created 2 invitations (email1 + email2) const stats = page.locator('.report-stats .stat'); const sentStat = stats.filter({ hasText: /envoyées/ }); await expect(sentStat.locator('.stat-value')).toHaveText('2'); try { unlinkSync(csvPath); } catch { /* ignore */ } // Cleanup try { runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT2_ID}' AND parent_email IN ('${email1}', '${email2}')`); } catch { /* ignore */ } }); test('[P1] shows invalid email errors in preview', async ({ page }) => { const csvContent = `Nom élève;Email parent 1\n${STUDENT1_LAST} ${STUDENT1_FIRST};not-an-email\n`; const csvPath = createCsvFixture('e2e-parent-import-invalid-email.csv', csvContent); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); await page.locator('input[type="file"]').setInputFiles(csvPath); // Mapping await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); await page.getByRole('button', { name: /valider le mapping/i }).click(); // Preview — should show 0 valid, 1 error await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 }); await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0'); await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1'); // Error detail should mention invalid email await expect(page.locator('.error-detail').first()).toContainText(/invalide/i); // Send button should be disabled (0 valid rows) await expect(page.getByRole('button', { name: /envoyer/i })).toBeDisabled(); try { unlinkSync(csvPath); } catch { /* ignore */ } }); test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => { const csvContent = [ 'Nom élève;Email parent 1', 'Eleve Un;parent1@test.fr', 'Eleve Deux;parent2@test.fr', 'Eleve Trois;parent3@test.fr', 'Eleve Quatre;parent4@test.fr', 'Eleve Cinq;parent5@test.fr', 'Eleve Six;parent6@test.fr', 'Eleve Sept;parent7@test.fr' ].join('\n') + '\n'; const csvPath = createCsvFixture('e2e-parent-import-preview-5.csv', csvContent); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/import/parents`); await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 }); await page.locator('input[type="file"]').setInputFiles(csvPath); // Wait for mapping step await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 }); // Verify preview section exists await expect(page.locator('.preview-section')).toBeVisible(); // Verify exactly 5 rows in the preview table (not 7) await expect(page.locator('.preview-table tbody tr')).toHaveCount(5); // Verify total row count in file info await expect(page.getByText(/7 lignes/i)).toBeVisible(); try { unlinkSync(csvPath); } catch { /* ignore */ } }); test.afterAll(async () => { // Clean up test students try { runCommand(`DELETE FROM parent_invitations WHERE student_id IN ('${STUDENT1_ID}', '${STUDENT2_ID}')`); runCommand(`DELETE FROM users WHERE id IN ('${STUDENT1_ID}', '${STUDENT2_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 */ } }); });