Files
Classeo/frontend/e2e/guardian-management.spec.ts
Mathias STRASSER b7dc27f2a5
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-04-04 02:25:00 +02:00

309 lines
12 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}`;
// Test credentials
const ADMIN_EMAIL = 'e2e-guardian-admin@example.com';
const ADMIN_PASSWORD = 'GuardianTest123';
const STUDENT_EMAIL = 'e2e-guardian-student@example.com';
const STUDENT_PASSWORD = 'StudentTest123';
const PARENT_EMAIL = 'e2e-guardian-parent@example.com';
const PARENT_PASSWORD = 'ParentTest123';
const PARENT_FIRST_NAME = 'GuardParent';
const PARENT_LAST_NAME = 'TestOne';
const PARENT2_EMAIL = 'e2e-guardian-parent2@example.com';
const PARENT2_PASSWORD = 'Parent2Test123';
const PARENT2_FIRST_NAME = 'GuardParent';
const PARENT2_LAST_NAME = 'TestTwo';
let studentUserId: string;
/**
* Extracts the User ID from the Symfony console table output.
*
* The create-test-user command outputs a table like:
* | Property | Value |
* | User ID | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
*/
function extractUserId(output: string): string {
const match = output.match(/User ID\s+([a-f0-9-]{36})/i);
if (!match) {
throw new Error(`Could not extract User ID from command output:\n${output}`);
}
return match[1];
}
test.describe('Guardian Management', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
// 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 student user and capture userId
const studentOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
studentUserId = extractUserId(studentOutput);
// Create first parent user (with name for search-based linking)
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`,
{ encoding: 'utf-8' }
);
// Create second parent user for the max guardians test
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT2_EMAIL} --password=${PARENT2_PASSWORD} --role=ROLE_PARENT --firstName=${PARENT2_FIRST_NAME} --lastName=${PARENT2_LAST_NAME} 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up any existing guardian links for this student (DB + cache)
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM student_guardians WHERE student_id = '${studentUserId}'" 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear student_guardians.cache --env=dev 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Ignore cleanup errors -- table may not have data yet
}
});
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()
]);
}
/**
* Waits for the guardian section to be fully hydrated (client-side JS loaded).
*
* The server renders the section with a "Chargement..." indicator. Only after
* client-side hydration does the $effect() fire, triggering loadGuardians().
* When that completes, either the empty-state or the guardian-list appears.
* Waiting for one of these ensures the component is interactive.
*/
async function waitForGuardianSection(page: import('@playwright/test').Page) {
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(
page.getByText(/aucun parent\/tuteur lié/i)
.or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
}
/**
* Opens the add-guardian dialog, searches for a parent by name,
* selects them from results, and submits.
* Waits for the success message before returning.
*/
async function addGuardianViaDialog(
page: import('@playwright/test').Page,
parentSearchTerm: string,
relationshipType: string
) {
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Type in search field and wait for results
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(parentSearchTerm);
// Wait for autocomplete results
const listbox = dialog.locator('#parent-search-listbox');
await expect(listbox).toBeVisible({ timeout: 10000 });
const option = listbox.locator('[role="option"]').first();
await option.click();
// Verify a parent was selected
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
await dialog.getByLabel(/type de relation/i).selectOption(relationshipType);
await dialog.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
}
/**
* Removes the first guardian in the list using the two-step confirmation.
* Waits for the success message before returning.
*/
async function removeFirstGuardian(page: import('@playwright/test').Page) {
const guardianItem = page.locator('.guardian-item').first();
await expect(guardianItem).toBeVisible();
await guardianItem.getByRole('button', { name: /retirer/i }).click();
await expect(guardianItem.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
await guardianItem.getByRole('button', { name: /oui/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
}
test('[P1] should display empty guardian list for student with no guardians', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Should show the empty state since no guardians are linked
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible();
// The "add guardian" button should be visible
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible();
});
test('[P1] should link a guardian to a student', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Add the guardian via the dialog (search by email)
await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
// Verify success message
await expect(page.locator('.alert-success')).toContainText(/parent ajouté/i);
// The guardian list should now contain the new item
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
const guardianItem = page.locator('.guardian-item').first();
await expect(guardianItem).toBeVisible();
await expect(guardianItem).toContainText('Père');
// Empty state should no longer be visible
await expect(page.getByText(/aucun parent\/tuteur lié/i)).not.toBeVisible();
});
test('[P1] should not show already-linked parent in search results', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// The parent from the previous test is still linked
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
// Open the add-guardian dialog and search for the already-linked parent
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(PARENT_EMAIL);
// The listbox should show "Aucun parent trouvé" since the parent is excluded
const listbox = dialog.locator('#parent-search-listbox');
await expect(listbox).toBeVisible({ timeout: 10000 });
await expect(listbox.getByText(/aucun parent trouvé/i)).toBeVisible();
// No option should be selectable
await expect(listbox.locator('[role="option"]')).toHaveCount(0);
// The guardian count should still be 1
await dialog.locator('.modal-close').click();
await expect(page.locator('.guardian-item')).toHaveCount(1);
});
test('[P1] should unlink a guardian from a student', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Wait for the guardian list to be loaded (from previous test)
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
// Remove the first guardian using the two-step confirmation
await removeFirstGuardian(page);
// Verify success message
await expect(page.locator('.alert-success')).toContainText(/liaison supprimée/i);
// The empty state should return since the only guardian was removed
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
});
test('[P2] should not show add button when maximum guardians reached', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Link first guardian (père) — search by email
await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
// Wait for the add button to still be available after first link
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible({ timeout: 5000 });
// Link second guardian (mère) — search by email
await addGuardianViaDialog(page, PARENT2_EMAIL, 'mère');
// Now with 2 guardians linked, the add button should NOT be visible
await expect(page.getByRole('button', { name: /ajouter un parent/i })).not.toBeVisible({ timeout: 5000 });
// Verify both guardian items are displayed
await expect(page.locator('.guardian-item')).toHaveCount(2);
// Clean up: remove both guardians so the state is clean for potential re-runs
await removeFirstGuardian(page);
await expect(page.locator('.guardian-item')).toHaveCount(1, { timeout: 5000 });
await removeFirstGuardian(page);
// Verify empty state returns
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
});
test('[P1] should clear search field when reopening add dialog after successful link', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Link a guardian via dialog (this fills the search, selects, and submits)
await addGuardianViaDialog(page, PARENT_EMAIL, 'père');
// Reopen the add-guardian dialog
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// The search field should be empty (reset after successful link)
await expect(dialog.getByRole('combobox', { name: /rechercher/i })).toHaveValue('');
// No parent should be marked as selected
await expect(dialog.getByText(/sélectionné/i)).not.toBeVisible();
// Close modal and clean up
await dialog.locator('.modal-close').click();
await removeFirstGuardian(page);
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
});
});