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,116 @@
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-actlink-admin@example.com';
const ADMIN_PASSWORD = 'ActLinkTest123';
const STUDENT_EMAIL = 'e2e-actlink-student@example.com';
const STUDENT_PASSWORD = 'StudentTest123';
const UNIQUE_SUFFIX = Date.now();
const PARENT_EMAIL = `e2e-actlink-parent-${UNIQUE_SUFFIX}@example.com`;
const PARENT_PASSWORD = 'ParentActivation1!';
let studentUserId: string;
let activationToken: string;
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('Activation with Parent-Child Auto-Link', () => {
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);
// Clean up any existing guardian links for this student
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' }
);
} catch {
// Ignore cleanup errors
}
// Create activation token for parent WITH student-id for auto-linking
const tokenOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-activation-token --email=${PARENT_EMAIL} --role=PARENT --tenant=ecole-alpha --student-id=${studentUserId} 2>&1`,
{ encoding: 'utf-8' }
);
const tokenMatch = tokenOutput.match(/Token\s+([a-f0-9-]{36})/i);
if (!tokenMatch) {
throw new Error(`Could not extract token from command output:\n${tokenOutput}`);
}
activationToken = tokenMatch[1];
});
test('[P1] should activate parent account and auto-link to student', async ({ page }) => {
// Navigate to the activation page
await page.goto(`${ALPHA_URL}/activate/${activationToken}`);
// Wait for the activation form to load
await expect(page.locator('#password')).toBeVisible({ timeout: 10000 });
// Fill the password form
await page.locator('#password').fill(PARENT_PASSWORD);
await page.locator('#passwordConfirmation').fill(PARENT_PASSWORD);
// Wait for validation to pass and submit
const submitButton = page.getByRole('button', { name: /activer mon compte/i });
await expect(submitButton).toBeEnabled({ timeout: 5000 });
await submitButton.click();
// Should redirect to login with activated=true
await page.waitForURL(/\/login\?activated=true/, { timeout: 15000 });
// Now login as admin to verify the auto-link
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 });
// Navigate to the student's page to check guardian list
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
// Wait for the guardian section to load
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(
page.locator('.guardian-list')
).toBeVisible({ timeout: 10000 });
// The auto-linked parent should appear in the guardian list
const guardianItem = page.locator('.guardian-item').first();
await expect(guardianItem).toBeVisible();
// Auto-linking uses RelationshipType::OTHER → label "Autre"
await expect(guardianItem).toContainText('Autre');
});
});