Files
Classeo/frontend/e2e/parent-invitation-import.spec.ts
Mathias STRASSER aedde6707e
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Calculer automatiquement les moyennes après chaque saisie de notes
Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
2026-03-31 16:43:10 +02:00

395 lines
16 KiB
TypeScript

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 */ }
});
});