feat: Liaison parents-enfants avec gestion des tuteurs

Les parents doivent pouvoir suivre la scolarité de leurs enfants (notes,
emploi du temps, devoirs). Cela nécessite un lien formalisé entre le
compte parent et le compte élève, géré par les administrateurs.

Le lien est établi soit manuellement via l'interface d'administration,
soit automatiquement lors de l'activation du compte parent lorsque
l'invitation inclut un élève cible. Ce lien conditionne l'accès aux
données scolaires de l'enfant (autorisations vérifiées par un voter
dédié).
This commit is contained in:
2026-02-12 08:38:19 +01:00
parent e930c505df
commit 44ebe5e511
91 changed files with 10071 additions and 39 deletions

View File

@@ -0,0 +1,385 @@
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);
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
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-students-admin@example.com';
const ADMIN_PASSWORD = 'StudentsTest123';
const STUDENT_EMAIL = 'e2e-students-eleve@example.com';
const STUDENT_PASSWORD = 'StudentTest123';
const PARENT_EMAIL = 'e2e-students-parent@example.com';
const PARENT_PASSWORD = 'ParentTest123';
let studentUserId: string;
let parentUserId: 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('Student 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 parent user and capture userId
const parentOutput = 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 2>&1`,
{ encoding: 'utf-8' }
);
parentUserId = extractUserId(parentOutput);
// 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
}
});
// Helper to login as admin
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 page.getByRole('button', { name: /se connecter/i }).click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
}
/**
* 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 });
}
// ============================================================================
// Student Detail Page - Navigation
// ============================================================================
test.describe('Navigation', () => {
test('can access student detail page via direct URL', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
// Page should load with the student detail heading
await expect(page.getByRole('heading', { name: /fiche élève/i })).toBeVisible({ timeout: 10000 });
});
test('page title is set correctly', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await expect(page).toHaveTitle(/fiche élève/i);
});
test('back link navigates to users page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
// Wait for page to be fully loaded
await expect(page.getByRole('heading', { name: /fiche élève/i })).toBeVisible({ timeout: 10000 });
// Click the back link
await page.locator('.back-link').click();
// Should navigate to users page
await expect(page).toHaveURL(/\/admin\/users/);
});
});
// ============================================================================
// Student Detail Page - Guardian Section
// ============================================================================
test.describe('Guardian Section', () => {
test('shows 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
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('displays the guardian section header', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Section title should be visible
await expect(page.getByRole('heading', { name: /parents \/ tuteurs/i })).toBeVisible();
});
test('can open add guardian modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Click the add guardian button
await page.getByRole('button', { name: /ajouter un parent/i }).click();
// Modal should appear
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Modal should have the correct heading
await expect(dialog.getByRole('heading', { name: /ajouter un parent\/tuteur/i })).toBeVisible();
// Form fields should be present
await expect(dialog.getByLabel(/id du parent/i)).toBeVisible();
await expect(dialog.getByLabel(/type de relation/i)).toBeVisible();
});
test('can cancel adding a guardian', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Open the modal
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Click cancel
await dialog.getByRole('button', { name: /annuler/i }).click();
// Modal should close
await expect(dialog).not.toBeVisible();
// Empty state should remain
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible();
});
test('can link a guardian to a student', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Open the add guardian modal
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Fill in the guardian details
await dialog.getByLabel(/id du parent/i).fill(parentUserId);
await dialog.getByLabel(/type de relation/i).selectOption('père');
// Submit
await dialog.getByRole('button', { name: 'Ajouter' }).click();
// Success message should appear
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
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('can 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 });
// Click remove on the first guardian
const guardianItem = page.locator('.guardian-item').first();
await expect(guardianItem).toBeVisible();
await guardianItem.getByRole('button', { name: /retirer/i }).click();
// Two-step confirmation should appear
await expect(guardianItem.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
await guardianItem.getByRole('button', { name: /oui/i }).click();
// Success message should appear
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/liaison supprimée/i);
// The empty state should return
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
});
test('can cancel guardian removal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// First, add a guardian to have something to remove
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByLabel(/id du parent/i).fill(parentUserId);
await dialog.getByLabel(/type de relation/i).selectOption('mère');
await dialog.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
// Now try to remove but cancel
const guardianItem = page.locator('.guardian-item').first();
await expect(guardianItem).toBeVisible();
await guardianItem.getByRole('button', { name: /retirer/i }).click();
// Confirmation should appear
await expect(guardianItem.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
// Cancel the removal
await guardianItem.getByRole('button', { name: /non/i }).click();
// Guardian should still be in the list
await expect(page.locator('.guardian-item')).toHaveCount(1);
});
test('relationship type options are available in the modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Open the add guardian modal
await page.getByRole('button', { name: /ajouter un parent/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Verify all relationship options are available
const select = dialog.getByLabel(/type de relation/i);
const options = select.locator('option');
// Count options (should include: père, mère, tuteur, tutrice, grand-père, grand-mère, autre)
const count = await options.count();
expect(count).toBeGreaterThanOrEqual(7);
// Verify some specific options exist (use exact match to avoid substring matches like Grand-père)
await expect(options.filter({ hasText: /^Père$/ })).toHaveCount(1);
await expect(options.filter({ hasText: /^Mère$/ })).toHaveCount(1);
await expect(options.filter({ hasText: /^Tuteur$/ })).toHaveCount(1);
// Close modal
await dialog.getByRole('button', { name: /annuler/i }).click();
});
});
// ============================================================================
// Student Detail Page - Access from Users Page
// ============================================================================
test.describe('Access from Users Page', () => {
test('users page lists the student user', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
// Wait for users table to load
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// The student email should appear in the users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
await expect(studentRow).toBeVisible();
});
test('users table shows student role', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
// Wait for users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Find the student row and verify role
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
await expect(studentRow).toContainText(/élève/i);
});
});
// ============================================================================
// Cleanup - remove guardian links after all tests
// ============================================================================
test('cleanup: remove remaining guardian links', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
await waitForGuardianSection(page);
// Remove all remaining guardians
while (await page.locator('.guardian-item').count() > 0) {
const guardianItem = page.locator('.guardian-item').first();
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 });
// Wait for the list to update
await page.waitForTimeout(500);
}
// Verify empty state
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
});
});