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.
488 lines
20 KiB
TypeScript
488 lines
20 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-teacher-import-admin@example.com';
|
|
const ADMIN_PASSWORD = 'TeacherImportTest123';
|
|
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: 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('Teacher Import via CSV', () => {
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
let subjectId: 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' }
|
|
);
|
|
|
|
const { schoolId } = resolveDeterministicIds();
|
|
const suffix = Date.now().toString().slice(-8);
|
|
subjectId = `00000200-e2e0-4000-8000-${suffix}0001`;
|
|
|
|
// Create a subject for valid import rows
|
|
try {
|
|
runCommand(
|
|
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E Maths', 'MATH', NULL, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
|
|
);
|
|
} catch {
|
|
// Subject may already exist
|
|
}
|
|
|
|
// Clean up auto-created subjects from previous runs
|
|
try {
|
|
runCommand(`DELETE FROM teacher_assignments WHERE subject_id IN (SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name LIKE 'E2E AutoSubject%')`);
|
|
runCommand(`DELETE FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name LIKE 'E2E AutoSubject%'`);
|
|
} catch { /* ignore */ }
|
|
|
|
// Clean up saved column mappings from previous runs to avoid stale mapping suggestions
|
|
try {
|
|
runCommand(`DELETE FROM saved_teacher_column_mappings WHERE tenant_id = '${TENANT_ID}'`);
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('displays the import wizard page', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
await expect(page.getByRole('heading', { name: /import d'enseignants/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;Prénom;Email\nDupont;Jean;jean.dupont@ecole.fr\nMartin;Marie;marie.martin@ecole.fr\n';
|
|
const csvPath = createCsvFixture('e2e-teacher-import-test.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
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-teacher-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: /^Email$/ })).toBeVisible();
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('validates required fields in mapping', async ({ page }) => {
|
|
const csvContent = 'Nom;Prénom;Email\nDupont;Jean;jean@ecole.fr\n';
|
|
const csvPath = createCsvFixture('e2e-teacher-import-required.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
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();
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('navigates back from mapping to upload', async ({ page }) => {
|
|
const csvContent = 'Nom;Prénom;Email\nDupont;Jean;jean@ecole.fr\n';
|
|
const csvPath = createCsvFixture('e2e-teacher-import-back.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
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 txtPath = createCsvFixture('e2e-teacher-import-bad.pdf', 'not a csv file');
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
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 });
|
|
|
|
try { unlinkSync(txtPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('shows preview step with valid/error counts', async ({ page }) => {
|
|
const csvContent =
|
|
'Nom;Prénom;Email\nDupont;Jean;jean.preview@ecole.fr\n;Marie;marie@ecole.fr\nMartin;;martin@ecole.fr\n';
|
|
const csvPath = createCsvFixture('e2e-teacher-import-preview.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
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();
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P0] completes full import flow with progress and report', async ({ page }) => {
|
|
const suffix = Date.now().toString().slice(-6);
|
|
const email1 = `alice.prof.${suffix}@ecole.fr`;
|
|
const email2 = `bob.prof.${suffix}@ecole.fr`;
|
|
const csvContent = `Nom;Prénom;Email\nTestProf${suffix};Alice;${email1}\nTestProf${suffix};Bob;${email2}\n`;
|
|
const csvPath = createCsvFixture('e2e-teacher-import-full-flow.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
// 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
|
|
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: /voir les utilisateurs/i })).toBeVisible();
|
|
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
|
|
// Cleanup
|
|
try {
|
|
runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${email1}', '${email2}'))`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${email1}', '${email2}')`);
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P1] imports only valid rows when errors exist', async ({ page }) => {
|
|
const suffix = Date.now().toString().slice(-6);
|
|
const validEmail = `sophie.durand.${suffix}@ecole.fr`;
|
|
const csvContent = `Nom;Prénom;Email\nDurand${suffix};Sophie;${validEmail}\n;Marie;marie.err@ecole.fr\nMartin;;martin.err@ecole.fr\n`;
|
|
const csvPath = createCsvFixture('e2e-teacher-import-valid-only.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
// 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 teacher 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 teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${validEmail}')`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${validEmail}'`);
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P1] shows unknown subjects and allows auto-creation', async ({ page }) => {
|
|
const suffix = Date.now().toString().slice(-6);
|
|
const subjectName = `E2E AutoSubject${suffix}`;
|
|
const email = `paul.lemaire.${suffix}@ecole.fr`;
|
|
const csvContent = `Nom;Prénom;Email;Matières\nLemaire${suffix};Paul;${email};${subjectName}\n`;
|
|
const csvPath = createCsvFixture('e2e-teacher-import-auto-subject.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
// 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 subjects section
|
|
await expect(page.locator('.unknown-items').first()).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.item-tag').first()).toContainText(subjectName);
|
|
|
|
// Check auto-create checkbox
|
|
await page.locator('.unknown-items input[type="checkbox"]').check();
|
|
|
|
// Launch import
|
|
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 teacher 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 teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${email}')`);
|
|
runCommand(`DELETE FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name = '${subjectName}'`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${email}'`);
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => {
|
|
const csvContent = [
|
|
'Nom;Prénom;Email',
|
|
'Alpha;Un;alpha.un@ecole.fr',
|
|
'Bravo;Deux;bravo.deux@ecole.fr',
|
|
'Charlie;Trois;charlie.trois@ecole.fr',
|
|
'Delta;Quatre;delta.quatre@ecole.fr',
|
|
'Echo;Cinq;echo.cinq@ecole.fr',
|
|
'Foxtrot;Six;foxtrot.six@ecole.fr',
|
|
'Golf;Sept;golf.sept@ecole.fr',
|
|
'Hotel;Huit;hotel.huit@ecole.fr'
|
|
].join('\n') + '\n';
|
|
const csvPath = createCsvFixture('e2e-teacher-import-preview-5.csv', csvContent);
|
|
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
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('[P1] skips teachers with duplicate emails and shows duplicate card', async ({ page }) => {
|
|
const DUPLICATE_EMAIL = 'e2e-duplicate-teacher@ecole.fr';
|
|
const UNIQUE_EMAIL = `e2e-unique-teacher-${Date.now()}@ecole.fr`;
|
|
|
|
// Clean up any stale user from previous runs (DB + cache) to avoid cache/DB desync
|
|
try {
|
|
runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${DUPLICATE_EMAIL}')`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${DUPLICATE_EMAIL}'`);
|
|
} catch { /* ignore */ }
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
// Create a pre-existing user with the duplicate email
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${DUPLICATE_EMAIL} --password=Unused123 --role=ROLE_PROF 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
const csvContent = `Nom;Prénom;Email\nExistant;Dupli;${DUPLICATE_EMAIL}\nNouveau;Unique;${UNIQUE_EMAIL}\n`;
|
|
const csvPath = createCsvFixture('e2e-teacher-import-duplicate-email.csv', csvContent);
|
|
|
|
try {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
|
|
|
// 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 — duplicates are detected and shown in the summary
|
|
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
|
|
|
// Verify duplicate card is visible
|
|
await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('1');
|
|
|
|
// "Ignorer les doublons" radio should be default
|
|
const ignoreRadio = page.locator('input[type="radio"][name="duplicateMode"][value="false"]');
|
|
await expect(ignoreRadio).toBeChecked();
|
|
|
|
// Launch import with "ignore" mode (default)
|
|
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
|
|
|
// Step 4: Report — wait for completion
|
|
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
|
|
|
// Verify report: 1 imported (unique email), 0 errors (duplicate was filtered by "import valid only")
|
|
const stats = page.locator('.report-stats .stat');
|
|
const importedStat = stats.filter({ hasText: /importés/ });
|
|
await expect(importedStat.locator('.stat-value')).toHaveText('1');
|
|
const errorStat = stats.filter({ hasText: /erreurs/ });
|
|
await expect(errorStat.locator('.stat-value')).toHaveText('0');
|
|
} finally {
|
|
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
|
|
|
// Cleanup: remove both the pre-existing and imported users (DB + cache)
|
|
try {
|
|
runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${DUPLICATE_EMAIL}', '${UNIQUE_EMAIL}'))`);
|
|
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${DUPLICATE_EMAIL}', '${UNIQUE_EMAIL}')`);
|
|
} catch { /* ignore */ }
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch { /* ignore */ }
|
|
}
|
|
});
|
|
});
|