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).
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { execSync } from 'child_process';
|
|
import { join, dirname } from 'path';
|
|
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-inv-admin@example.com';
|
|
const ADMIN_PASSWORD = 'ParentInvTest123';
|
|
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);
|
|
const STUDENT_ID = `e2e00002-0000-4000-8000-${UNIQUE_SUFFIX}0001`;
|
|
const PARENT_EMAIL = `e2e-parent-inv-${UNIQUE_SUFFIX}@test.fr`;
|
|
|
|
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()
|
|
]);
|
|
}
|
|
|
|
test.describe('Parent Invitations', () => {
|
|
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 a student with known name for invite tests
|
|
// 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 ('${STUDENT_ID}', '${TENANT_ID}', NULL, 'Camille', 'Testinv', '[\\"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 */ }
|
|
|
|
// Clean up invitations from previous runs
|
|
try {
|
|
runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT_ID}' OR parent_email = '${PARENT_EMAIL}'`);
|
|
} catch { /* ignore */ }
|
|
|
|
// Clear user 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 */ }
|
|
});
|
|
|
|
test('admin can navigate to parent invitations page', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
// Page should load (empty state or table)
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Title should be visible
|
|
await expect(page.getByRole('heading', { name: /invitations parents/i })).toBeVisible();
|
|
});
|
|
|
|
test('admin sees empty state or data table', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
// Wait for page to load — either empty state or data table
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Verify whichever state is shown has correct content
|
|
const emptyState = page.locator('.empty-state');
|
|
const dataTable = page.locator('.data-table');
|
|
const isEmptyStateVisible = await emptyState.isVisible();
|
|
|
|
if (isEmptyStateVisible) {
|
|
await expect(emptyState.getByText(/aucune invitation/i)).toBeVisible();
|
|
} else {
|
|
await expect(dataTable).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('admin can open the invite modal', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click "Inviter les parents" button
|
|
await page.getByRole('button', { name: /inviter les parents/i }).first().click();
|
|
|
|
// Modal should appear
|
|
await expect(page.locator('#invite-modal-title')).toBeVisible();
|
|
await expect(page.locator('#invite-modal-title')).toHaveText('Inviter les parents');
|
|
|
|
// Form fields should be visible
|
|
await expect(page.locator('#invite-student')).toBeVisible();
|
|
await expect(page.locator('#invite-email1')).toBeVisible();
|
|
await expect(page.locator('#invite-email2')).toBeVisible();
|
|
});
|
|
|
|
test('admin can close the invite modal with Escape', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Open modal
|
|
await page.getByRole('button', { name: /inviter les parents/i }).first().click();
|
|
await expect(page.locator('#invite-modal-title')).toBeVisible();
|
|
|
|
// Close with Escape
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.locator('#invite-modal-title')).not.toBeVisible();
|
|
});
|
|
|
|
test('send invitation requires student and email', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Open modal
|
|
await page.getByRole('button', { name: /inviter les parents/i }).first().click();
|
|
await expect(page.locator('#invite-modal-title')).toBeVisible();
|
|
|
|
// Submit button should be disabled when empty
|
|
const submitBtn = page.locator('.modal').getByRole('button', { name: /envoyer l'invitation/i });
|
|
await expect(submitBtn).toBeDisabled();
|
|
});
|
|
|
|
test('[P0] admin can create an invitation via modal', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Open modal
|
|
await page.getByRole('button', { name: /inviter les parents/i }).first().click();
|
|
await expect(page.locator('#invite-modal-title')).toBeVisible();
|
|
|
|
// Wait for students to load in select (more than just the default empty option)
|
|
await expect(page.locator('#invite-student option')).not.toHaveCount(1, { timeout: 10000 });
|
|
|
|
// Select the first available student (not the placeholder)
|
|
const firstStudentOption = page.locator('#invite-student option:not([value=""])').first();
|
|
await expect(firstStudentOption).toBeAttached({ timeout: 10000 });
|
|
const studentValue = await firstStudentOption.getAttribute('value');
|
|
await page.locator('#invite-student').selectOption(studentValue!);
|
|
|
|
// Fill parent email
|
|
await page.locator('#invite-email1').fill(PARENT_EMAIL);
|
|
|
|
// Submit button should be enabled
|
|
const submitBtn = page.locator('.modal').getByRole('button', { name: /envoyer l'invitation/i });
|
|
await expect(submitBtn).toBeEnabled();
|
|
|
|
// Submit
|
|
await submitBtn.click();
|
|
|
|
// Modal should close and success message should appear
|
|
await expect(page.locator('#invite-modal-title')).not.toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.alert-success')).toContainText(/invitation.*envoyée/i);
|
|
});
|
|
|
|
test('[P0] invitation appears in the table after creation', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
// Wait for table to load (should no longer be empty state)
|
|
await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 });
|
|
|
|
// The invitation should appear with the parent email
|
|
await expect(page.locator('.data-table').getByText(PARENT_EMAIL)).toBeVisible();
|
|
|
|
// Student name should appear (any student name in the row)
|
|
const invitationRow = page.locator('tr').filter({ hasText: PARENT_EMAIL });
|
|
await expect(invitationRow).toBeVisible();
|
|
|
|
// Status should be "Envoyée"
|
|
await expect(page.locator('.data-table .status-badge').first()).toContainText(/envoyée/i);
|
|
});
|
|
|
|
test('[P1] admin can resend an invitation', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 });
|
|
|
|
// Find the row with our invitation and click "Renvoyer"
|
|
const row = page.locator('tr').filter({ hasText: PARENT_EMAIL });
|
|
await expect(row).toBeVisible();
|
|
await row.getByRole('button', { name: /renvoyer/i }).click();
|
|
|
|
// Success message should appear
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.alert-success')).toContainText(/renvoyée/i);
|
|
});
|
|
|
|
test('admin can navigate to file import page', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click "Importer un fichier" link
|
|
await page.getByRole('link', { name: /importer un fichier/i }).click();
|
|
|
|
// Should navigate to the import wizard page
|
|
await expect(page).toHaveURL(/\/admin\/import\/parents/);
|
|
await expect(page.getByRole('heading', { name: /import d'invitations parents/i })).toBeVisible({
|
|
timeout: 15000
|
|
});
|
|
});
|
|
|
|
test('filter by status changes the URL', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Select a status filter
|
|
await page.locator('#filter-status').selectOption('sent');
|
|
await page.getByRole('button', { name: /filtrer/i }).click();
|
|
|
|
// URL should have status param
|
|
await expect(page).toHaveURL(/status=sent/);
|
|
});
|
|
|
|
test('reset filters clears URL params', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations?status=sent`);
|
|
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Click reset (exact match to avoid ambiguity with "Réinitialiser les filtres" in empty state)
|
|
await page.getByRole('button', { name: 'Réinitialiser', exact: true }).click();
|
|
|
|
// URL should no longer contain status param
|
|
await expect(page).not.toHaveURL(/status=/);
|
|
});
|
|
|
|
test('[P1] filter by sent status shows the created invitation', async ({ page }) => {
|
|
await loginAsAdmin(page);
|
|
await page.goto(`${ALPHA_URL}/admin/parent-invitations`);
|
|
|
|
await expect(
|
|
page.locator('.data-table, .empty-state')
|
|
).toBeVisible({ timeout: 10000 });
|
|
|
|
// Filter by "sent" status
|
|
await page.locator('#filter-status').selectOption('sent');
|
|
await page.getByRole('button', { name: /filtrer/i }).click();
|
|
|
|
// Our invitation should still be visible
|
|
await expect(page.locator('.data-table')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.locator('.data-table').getByText(PARENT_EMAIL)).toBeVisible();
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
// Clean up invitations (by student or by email) and student
|
|
try {
|
|
runCommand(`DELETE FROM parent_invitations WHERE student_id = '${STUDENT_ID}' OR parent_email = '${PARENT_EMAIL}'`);
|
|
runCommand(`DELETE FROM users WHERE id = '${STUDENT_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 */ }
|
|
});
|
|
});
|
|
|
|
test.describe('Parent Activation Page', () => {
|
|
test('displays form for parent activation page', async ({ page }) => {
|
|
// Navigate to the parent activation page with a dummy code
|
|
await page.goto('/parent-activate/test-code-that-does-not-exist');
|
|
|
|
// Page should load
|
|
await expect(page.getByRole('heading', { name: /activation.*parent/i })).toBeVisible();
|
|
|
|
// Form fields should be visible
|
|
await expect(page.locator('#firstName')).toBeVisible();
|
|
await expect(page.locator('#lastName')).toBeVisible();
|
|
await expect(page.locator('#password')).toBeVisible();
|
|
await expect(page.locator('#passwordConfirmation')).toBeVisible();
|
|
});
|
|
|
|
test('validates password requirements in real-time', async ({ page }) => {
|
|
await page.goto('/parent-activate/test-code');
|
|
|
|
await expect(page.locator('#password')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Type a weak password
|
|
await page.locator('#password').fill('abc');
|
|
|
|
// Check that requirements are shown
|
|
const requirements = page.locator('.password-requirements');
|
|
await expect(requirements).toBeVisible();
|
|
|
|
// Min length should NOT be valid
|
|
const minLengthItem = requirements.locator('li').filter({ hasText: /8 caractères/ });
|
|
await expect(minLengthItem).not.toHaveClass(/valid/);
|
|
|
|
// Type a strong password
|
|
await page.locator('#password').fill('StrongP@ss1');
|
|
|
|
// All requirements should be valid
|
|
const allItems = requirements.locator('li.valid');
|
|
await expect(allItems).toHaveCount(5);
|
|
});
|
|
|
|
test('validates password confirmation match', async ({ page }) => {
|
|
await page.goto('/parent-activate/test-code');
|
|
|
|
await expect(page.locator('#password')).toBeVisible({ timeout: 5000 });
|
|
|
|
await page.locator('#password').fill('StrongP@ss1');
|
|
await page.locator('#passwordConfirmation').fill('DifferentPass');
|
|
|
|
// Error should show
|
|
await expect(page.getByText(/ne correspondent pas/i)).toBeVisible();
|
|
});
|
|
|
|
test('submit button is disabled until form is valid', async ({ page }) => {
|
|
await page.goto('/parent-activate/test-code');
|
|
|
|
await expect(page.locator('#password')).toBeVisible({ timeout: 5000 });
|
|
|
|
// Submit should be disabled initially
|
|
const submitBtn = page.getByRole('button', { name: /activer mon compte/i });
|
|
await expect(submitBtn).toBeDisabled();
|
|
|
|
// Fill all fields with valid data
|
|
await page.locator('#firstName').fill('Jean');
|
|
await page.locator('#lastName').fill('Parent');
|
|
await page.locator('#password').fill('StrongP@ss1');
|
|
await page.locator('#passwordConfirmation').fill('StrongP@ss1');
|
|
|
|
// Submit should be enabled
|
|
await expect(submitBtn).toBeEnabled();
|
|
});
|
|
});
|