feat: Permettre l'import d'enseignants via fichier CSV ou XLSX
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.
This commit is contained in:
@@ -265,7 +265,8 @@ test.describe('Student Import via CSV', () => {
|
||||
});
|
||||
|
||||
test('[P0] completes full import flow with progress and report', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Classe\nTestImport;Alice;E2E Import A\nTestImport;Bob;E2E Import A\n';
|
||||
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);
|
||||
@@ -298,10 +299,17 @@ test.describe('Student Import via CSV', () => {
|
||||
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 csvContent = 'Nom;Prénom;Classe\nDurand;Sophie;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n';
|
||||
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);
|
||||
@@ -340,10 +348,17 @@ test.describe('Student Import via CSV', () => {
|
||||
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 csvContent = 'Nom;Prénom;Classe\nLemaire;Paul;E2E NewAutoClass\n';
|
||||
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);
|
||||
@@ -364,13 +379,11 @@ test.describe('Student Import via CSV', () => {
|
||||
await expect(page.locator('.unknown-classes')).toBeVisible();
|
||||
await expect(page.locator('.class-tag')).toContainText('E2E NewAutoClass');
|
||||
|
||||
// Check auto-create checkbox
|
||||
// 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();
|
||||
|
||||
// Select "import all rows" since unknown class makes row invalid (validCount=0)
|
||||
await page.locator('input[type="radio"][name="importMode"][value="false"]').check();
|
||||
|
||||
// Launch import
|
||||
// Launch import (no need for radio — adjustedValidCount is now 1)
|
||||
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
||||
|
||||
// Wait for completion
|
||||
@@ -383,9 +396,10 @@ test.describe('Student Import via CSV', () => {
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup: delete assignments then class (FK constraint)
|
||||
// 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 */ }
|
||||
});
|
||||
@@ -490,4 +504,88 @@ test.describe('Student Import via CSV', () => {
|
||||
|
||||
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 */ }
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user