feat: Permettre la génération et l'envoi de codes d'invitation aux parents
Les administrateurs ont besoin d'un moyen simple pour inviter les parents à rejoindre la plateforme. Cette fonctionnalité permet de générer des codes d'invitation uniques (8 caractères alphanumériques) avec une validité de 48h, de les envoyer par email, et de les activer via une page publique dédiée qui crée automatiquement le compte parent. L'interface d'administration offre l'envoi unitaire et en masse, le renvoi, le filtrage par statut, ainsi que la visualisation de l'état de chaque invitation (en attente, activée, expirée).
This commit is contained in:
394
frontend/e2e/parent-invitation-import.spec.ts
Normal file
394
frontend/e2e/parent-invitation-import.spec.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
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: 30000 }),
|
||||
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 */ }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user