L'établissement a besoin d'importer en masse ses enseignants depuis les exports des logiciels de vie scolaire (Pronote, EDT, etc.), comme c'est déjà possible pour les élèves. Le wizard en 4 étapes (upload → mapping → aperçu → import) réutilise l'architecture de l'import élèves tout en ajoutant la gestion des matières et des classes enseignées. Corrections de la review #2 intégrées : - La commande ImportTeachersCommand est routée en async via Messenger pour ne pas bloquer la requête HTTP sur les gros fichiers. - Le handler est protégé par un try/catch Throwable pour marquer le batch en échec si une erreur inattendue survient, évitant qu'il reste bloqué en statut "processing". - Les domain events (UtilisateurInvite) sont dispatchés sur l'event bus après chaque création d'utilisateur, déclenchant l'envoi des emails d'invitation. - L'option "mettre à jour les enseignants existants" (AC5) permet de choisir entre ignorer ou mettre à jour nom/prénom et ajouter les affectations manquantes pour les doublons détectés par email.
592 lines
25 KiB
TypeScript
592 lines
25 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-import-admin@example.com';
|
|
const ADMIN_PASSWORD = 'ImportTest123';
|
|
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
|
|
|
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 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 Promise.all([
|
|
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
|
page.getByRole('button', { name: /se connecter/i }).click()
|
|
]);
|
|
}
|
|
|
|
// Create CSV fixture file for tests
|
|
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('Student Import via CSV', () => {
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
let classId: string;
|
|
|
|
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' }
|
|
);
|
|
|
|
// Clean up auto-created class from previous runs (FK: assignments first)
|
|
try {
|
|
runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`);
|
|
runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`);
|
|
} catch { /* ignore */ }
|
|
|
|
// Create a class for valid import rows
|
|
const { schoolId, academicYearId } = resolveDeterministicIds();
|
|
const suffix = Date.now().toString().slice(-8);
|
|
classId = `00000100-e2e0-4000-8000-${suffix}0001`;
|
|
|
|
try {
|
|
runCommand(
|
|
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Import A', NULL, NULL, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
|
|
);
|
|
} catch {
|
|
// Class may already exist
|
|
}
|
|
});
|
|
|
|
test('displays the import wizard page', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
await expect(page.getByRole('heading', { name: /import d'élèves/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('shows format help cards', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({
|
|
timeout: 15000
|
|
});
|
|
|
|
await expect(page.getByText(/formats supportés/i)).toBeVisible();
|
|
await expect(page.getByText('Pronote', { exact: true })).toBeVisible();
|
|
await expect(page.getByText('EcoleDirecte', { exact: true })).toBeVisible();
|
|
await expect(page.getByText(/personnalisé/i)).toBeVisible();
|
|
});
|
|
|
|
test('uploads a CSV file and shows mapping step', async ({ page }) => {
|
|
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\nMartin;Marie;E2E Import A\n';
|
|
const csvPath = createCsvFixture('e2e-import-test.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Upload via file input
|
|
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-import-test\.csv/i)).toBeVisible();
|
|
await expect(page.getByText(/2 lignes/i)).toBeVisible();
|
|
|
|
// Column names should appear in mapping
|
|
await expect(page.locator('.column-name').filter({ hasText: /^Nom$/ })).toBeVisible();
|
|
await expect(page.locator('.column-name').filter({ hasText: /^Prénom$/ })).toBeVisible();
|
|
await expect(page.locator('.column-name').filter({ hasText: /^Classe$/ })).toBeVisible();
|
|
|
|
// Clean up
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('validates required fields in mapping', async ({ page }) => {
|
|
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n';
|
|
const csvPath = createCsvFixture('e2e-import-required.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
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();
|
|
|
|
// Clean up
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('navigates back from mapping to upload', async ({ page }) => {
|
|
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n';
|
|
const csvPath = createCsvFixture('e2e-import-back.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
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 });
|
|
|
|
// Clean up
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('rejects non-CSV files', async ({ page }) => {
|
|
const txtPath = createCsvFixture('e2e-import-bad.pdf', 'not a csv file');
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
|
|
|
const fileInput = page.locator('input[type="file"]');
|
|
await fileInput.setInputFiles(txtPath);
|
|
|
|
// Should show error
|
|
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Clean up
|
|
try { unlinkSync(txtPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('shows preview step with valid/error counts', async ({ page }) => {
|
|
const csvContent =
|
|
'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n';
|
|
const csvPath = createCsvFixture('e2e-import-preview.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
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 valid and error counts
|
|
await expect(page.locator('.summary-card.valid')).toBeVisible();
|
|
await expect(page.locator('.summary-card.error')).toBeVisible();
|
|
|
|
// Clean up
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('navigable from students page via import button', 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: 15000 });
|
|
|
|
// Click import link
|
|
const importLink = page.getByRole('link', { name: /importer.*csv/i });
|
|
await expect(importLink).toBeVisible();
|
|
await importLink.click();
|
|
|
|
// Should navigate to import page
|
|
await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({
|
|
timeout: 15000
|
|
});
|
|
});
|
|
|
|
test('[P0] completes full import flow with progress and report', async ({ page }) => {
|
|
const suffix = Date.now().toString().slice(-6);
|
|
const csvContent = `Nom;Prénom;Classe\nTestImport${suffix};Alice;E2E Import A\nTestImport${suffix};Bob;E2E Import A\n`;
|
|
const csvPath = createCsvFixture('e2e-import-full-flow.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
// 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 page.getByRole('button', { name: /lancer l'import/i }).click();
|
|
|
|
// Step 4: Confirmation — wait for completion (import may be too fast for progressbar to be visible)
|
|
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
|
|
|
// Verify report stats
|
|
const stats = page.locator('.report-stats .stat');
|
|
const importedStat = stats.filter({ hasText: /importés/ });
|
|
await expect(importedStat.locator('.stat-value')).toHaveText('2');
|
|
const errorStat = stats.filter({ hasText: /erreurs/ });
|
|
await expect(errorStat.locator('.stat-value')).toHaveText('0');
|
|
|
|
// Verify action buttons
|
|
await expect(page.getByRole('button', { name: /télécharger le rapport/i })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /voir les élèves/i })).toBeVisible();
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
|
|
// Cleanup imported students to avoid cross-run duplicate detection
|
|
try {
|
|
runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'TestImport${suffix}')`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'TestImport${suffix}'`);
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P1] imports only valid rows when errors exist', async ({ page }) => {
|
|
const suffix = Date.now().toString().slice(-6);
|
|
const csvContent = `Nom;Prénom;Classe\nDurand${suffix};Sophie;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n`;
|
|
const csvPath = createCsvFixture('e2e-import-valid-only.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
// 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
|
|
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Verify error count
|
|
await expect(page.locator('.summary-card.error .summary-number')).toHaveText('2');
|
|
|
|
// Verify error detail rows are visible
|
|
await expect(page.locator('.error-detail').first()).toBeVisible();
|
|
|
|
// "Import valid only" radio should be selected by default
|
|
const validOnlyRadio = page.locator('input[type="radio"][name="importMode"][value="true"]');
|
|
await expect(validOnlyRadio).toBeChecked();
|
|
|
|
// Launch import (should only import 1 valid row)
|
|
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
|
|
|
// Wait for completion
|
|
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
|
|
|
// Verify only 1 student imported
|
|
const stats = page.locator('.report-stats .stat');
|
|
const importedStat = stats.filter({ hasText: /importés/ });
|
|
await expect(importedStat.locator('.stat-value')).toHaveText('1');
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
|
|
// Cleanup
|
|
try {
|
|
runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Durand${suffix}')`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Durand${suffix}'`);
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P1] shows unknown classes and allows auto-creation', async ({ page }) => {
|
|
const suffix = Date.now().toString().slice(-6);
|
|
const csvContent = `Nom;Prénom;Classe\nLemaire${suffix};Paul;E2E NewAutoClass\n`;
|
|
const csvPath = createCsvFixture('e2e-import-auto-class.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
// 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
|
|
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Verify unknown classes section
|
|
await expect(page.locator('.unknown-classes')).toBeVisible();
|
|
await expect(page.locator('.class-tag')).toContainText('E2E NewAutoClass');
|
|
|
|
// Check auto-create checkbox — this resolves class errors,
|
|
// so the adjusted preview shows all rows as valid and the import button is enabled
|
|
await page.locator('.unknown-classes input[type="checkbox"]').check();
|
|
|
|
// Launch import (no need for radio — adjustedValidCount is now 1)
|
|
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
|
|
|
// Wait for completion
|
|
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
|
|
|
// Verify student imported
|
|
const stats = page.locator('.report-stats .stat');
|
|
const importedStat = stats.filter({ hasText: /importés/ });
|
|
await expect(importedStat.locator('.stat-value')).toHaveText('1');
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
|
|
// Cleanup: delete assignments, users, then class (FK constraint)
|
|
try {
|
|
runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Lemaire${suffix}'`);
|
|
runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`);
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P1] detects Pronote format and pre-fills mapping', async ({ page }) => {
|
|
// Pronote format needs 3+ matching columns: Élèves, Né(e) le, Sexe, Classe de rattachement
|
|
const csvContent = 'Élèves;Né(e) le;Sexe;Classe de rattachement\nDUPONT Jean;15/03/2010;M;E2E Import A\n';
|
|
const csvPath = createCsvFixture('e2e-import-pronote.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
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 format detection badge
|
|
await expect(page.locator('.format-badge')).toBeVisible();
|
|
await expect(page.locator('.format-badge')).toContainText('Pronote');
|
|
|
|
// Verify pre-filled mapping: Élèves → Nom complet (fullName)
|
|
const elevesRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Élèves$/ }) });
|
|
await expect(elevesRow.locator('select')).toHaveValue('fullName');
|
|
|
|
// Verify pre-filled mapping: Classe de rattachement → Classe (className)
|
|
const classeRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Classe de rattachement$/ }) });
|
|
await expect(classeRow.locator('select')).toHaveValue('className');
|
|
|
|
// Verify pre-filled mapping: Né(e) le → Date de naissance (birthDate)
|
|
const dateRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Né\(e\) le$/ }) });
|
|
await expect(dateRow.locator('select')).toHaveValue('birthDate');
|
|
|
|
// Verify pre-filled mapping: Sexe → Genre (gender)
|
|
const sexeRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Sexe$/ }) });
|
|
await expect(sexeRow.locator('select')).toHaveValue('gender');
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => {
|
|
// Create CSV with 8 data rows (more than the 5-line preview limit)
|
|
const csvContent = [
|
|
'Nom;Prénom;Classe',
|
|
'Alpha;Un;E2E Import A',
|
|
'Bravo;Deux;E2E Import A',
|
|
'Charlie;Trois;E2E Import A',
|
|
'Delta;Quatre;E2E Import A',
|
|
'Echo;Cinq;E2E Import A',
|
|
'Foxtrot;Six;E2E Import A',
|
|
'Golf;Sept;E2E Import A',
|
|
'Hotel;Huit;E2E Import A'
|
|
].join('\n') + '\n';
|
|
const csvPath = createCsvFixture('e2e-import-preview-5.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
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 heading shows 5 premières lignes
|
|
await expect(page.locator('.preview-section h3')).toContainText('5 premières lignes');
|
|
|
|
// Verify exactly 5 rows in the preview table (not 8)
|
|
await expect(page.locator('.preview-table tbody tr')).toHaveCount(5);
|
|
|
|
// Verify total row count in file info
|
|
await expect(page.getByText(/8 lignes/i)).toBeVisible();
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P2] rejects files exceeding 10 MB limit', async ({ page }) => {
|
|
// Create a CSV file that exceeds 10 MB
|
|
const header = 'Nom;Prénom;Classe\n';
|
|
const line = 'Dupont;Jean;E2E Import A\n';
|
|
const targetSize = 10 * 1024 * 1024 + 100; // just over 10 MB
|
|
const repeats = Math.ceil((targetSize - header.length) / line.length);
|
|
const content = header + line.repeat(repeats);
|
|
const csvPath = createCsvFixture('e2e-import-too-large.csv', content);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
|
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
|
|
|
// Should show error about file size
|
|
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByText(/dépasse la taille maximale de 10 Mo/i)).toBeVisible();
|
|
|
|
// Should stay on upload step (not transition to mapping)
|
|
await expect(page.locator('.dropzone')).toBeVisible();
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P1] clicking the dropzone opens the file picker', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Click the dropzone and verify the file chooser opens
|
|
const [fileChooser] = await Promise.all([
|
|
page.waitForEvent('filechooser', { timeout: 5000 }),
|
|
page.locator('.dropzone').click()
|
|
]);
|
|
|
|
// The file chooser was triggered — verify it accepts csv/xlsx
|
|
expect(fileChooser).toBeTruthy();
|
|
});
|
|
|
|
test('[P1] detects duplicate students in preview when re-importing same file', async ({ page }) => {
|
|
// First import: create students
|
|
const suffix = Date.now().toString().slice(-6);
|
|
const csvContent = `Nom;Prénom;Classe\nDupliTest${suffix};Alice;E2E Import A\nDupliTest${suffix};Bob;E2E Import A\n`;
|
|
const csvPath = createCsvFixture('e2e-import-dupli-first.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
// Upload → Mapping → Preview → Confirm (first import)
|
|
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
|
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
|
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
|
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
|
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
|
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
|
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
|
|
|
// Second import: same students should be detected as duplicates
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
|
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
|
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
|
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
|
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Should show duplicates detected
|
|
await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('2');
|
|
|
|
// All rows should be in error (duplicates)
|
|
await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0');
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
|
|
// Cleanup: remove imported students
|
|
try {
|
|
runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name LIKE 'DupliTest${suffix}')`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name LIKE 'DupliTest${suffix}'`);
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P1] detects intra-file duplicate students in preview', async ({ page }) => {
|
|
const suffix = Date.now().toString().slice(-6);
|
|
const csvContent = `Nom;Prénom;Classe\nIntraTest${suffix};Alice;E2E Import A\nIntraTest${suffix};Alice;E2E Import A\nIntraTest${suffix};Bob;E2E Import A\n`;
|
|
const csvPath = createCsvFixture('e2e-import-intra-dupli.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
|
|
|
// Upload → Mapping → Preview
|
|
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
|
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
|
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
|
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
|
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Should detect 1 intra-file duplicate (second Alice)
|
|
await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('1');
|
|
|
|
// 2 valid (first Alice + Bob), 1 error (second Alice)
|
|
await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('2');
|
|
await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1');
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
});
|