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:
235
frontend/e2e/guardian-management.spec.ts
Normal file
235
frontend/e2e/guardian-management.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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 PARENT2_EMAIL = 'e2e-guardian-parent2@example.com';
|
||||
const PARENT2_PASSWORD = 'Parent2Test123';
|
||||
|
||||
let studentUserId: string;
|
||||
let parentUserId: string;
|
||||
let parent2UserId: 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 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);
|
||||
|
||||
// Create second parent user for the max guardians test
|
||||
const parent2Output = 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 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
parent2UserId = extractUserId(parent2Output);
|
||||
|
||||
// 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 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the add-guardian dialog, fills the form, and submits.
|
||||
* Waits for the success message before returning.
|
||||
*/
|
||||
async function addGuardianViaDialog(
|
||||
page: import('@playwright/test').Page,
|
||||
guardianId: string,
|
||||
relationshipType: string
|
||||
) {
|
||||
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(guardianId);
|
||||
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
|
||||
await addGuardianViaDialog(page, parentUserId, '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 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)
|
||||
await addGuardianViaDialog(page, parentUserId, '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)
|
||||
await addGuardianViaDialog(page, parent2UserId, '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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user