Files
Classeo/frontend/e2e/parent-invitations.spec.ts
Mathias STRASSER aedde6707e
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Calculer automatiquement les moyennes après chaque saisie de notes
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.
2026-03-31 16:43:10 +02:00

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: 60000 }),
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();
});
});