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

@@ -1,26 +1,505 @@
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 for authenticated tests
const ADMIN_EMAIL = 'e2e-dashboard-admin@example.com';
const ADMIN_PASSWORD = 'DashboardTest123';
test.describe('Dashboard', () => {
// Dashboard shows demo content without authentication (Story 1.9)
test('shows demo content when not authenticated', async ({ page }) => {
await page.goto('/dashboard');
/**
* Navigate to the dashboard and wait for SvelteKit hydration.
* SSR renders the HTML immediately, but event handlers are only
* attached after client-side hydration completes.
*/
async function goToDashboard(page: import('@playwright/test').Page) {
await page.goto('/dashboard', { waitUntil: 'networkidle' });
await expect(page.locator('.demo-controls')).toBeVisible({ timeout: 5000 });
}
// Dashboard is accessible without auth - shows demo mode
await expect(page).toHaveURL(/\/dashboard/);
// Role switcher visible (shows demo banner)
await expect(page.getByText(/Démo - Changer de rôle/i)).toBeVisible();
/**
* Switch to a demo role with retry logic to handle hydration timing.
* Retries the click until the button's active class confirms the switch.
*/
async function switchToDemoRole(
page: import('@playwright/test').Page,
roleName: string | RegExp
) {
const button = page.locator('.demo-controls button', { hasText: roleName });
await expect(async () => {
await button.click();
await expect(button).toHaveClass(/active/, { timeout: 1000 });
}).toPass({ timeout: 10000 });
}
// ============================================================================
// Demo Mode (unauthenticated) - Role Switcher
// ============================================================================
test.describe('Demo Mode', () => {
test('shows demo role switcher when not authenticated', async ({ page }) => {
await goToDashboard(page);
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText(/Démo - Changer de rôle/i)).toBeVisible();
});
test('page title is set correctly', async ({ page }) => {
await goToDashboard(page);
await expect(page).toHaveTitle(/tableau de bord/i);
});
test('demo role switcher has all 4 role buttons', async ({ page }) => {
await goToDashboard(page);
const demoControls = page.locator('.demo-controls');
await expect(demoControls).toBeVisible();
await expect(demoControls.getByRole('button', { name: 'Parent' })).toBeVisible();
await expect(demoControls.getByRole('button', { name: 'Enseignant' })).toBeVisible();
await expect(demoControls.getByRole('button', { name: /Élève/i })).toBeVisible();
await expect(demoControls.getByRole('button', { name: 'Admin' })).toBeVisible();
});
test('Parent role is selected by default', async ({ page }) => {
await goToDashboard(page);
const parentButton = page.locator('.demo-controls button', { hasText: 'Parent' });
await expect(parentButton).toHaveClass(/active/);
});
});
test.describe('when authenticated', () => {
// These tests would run with a logged-in user
// For now, we test the public behavior
// ============================================================================
// Parent Dashboard View
// ============================================================================
test.describe('Parent Dashboard', () => {
test('shows Score Serenite card', async ({ page }) => {
await goToDashboard(page);
test('dashboard page exists and loads', async ({ page }) => {
// First, try to access dashboard
const response = await page.goto('/dashboard');
// Parent is the default demo role
await expect(page.getByText(/score sérénité/i).first()).toBeVisible();
});
// The page should load (even if it redirects)
expect(response?.status()).toBeLessThan(500);
test('shows serenity score with numeric value', async ({ page }) => {
await goToDashboard(page);
// The score card should display a number value
const scoreCard = page.locator('.serenity-card');
await expect(scoreCard).toBeVisible();
// Should have a numeric value followed by /100
await expect(scoreCard.locator('.value')).toBeVisible();
await expect(scoreCard.getByText('/100')).toBeVisible();
});
test('serenity score shows demo badge', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByText(/données de démonstration/i)).toBeVisible();
});
test('shows placeholder sections for schedule, notes, and homework', async ({ page }) => {
await goToDashboard(page);
// These sections show as placeholders since hasRealData is false
await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /notes récentes/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /devoirs à venir/i })).toBeVisible();
});
test('placeholder sections show informative messages', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByText(/l'emploi du temps sera disponible/i)).toBeVisible();
await expect(page.getByText(/les notes apparaîtront ici/i)).toBeVisible();
await expect(page.getByText(/les devoirs seront affichés ici/i)).toBeVisible();
});
test('onboarding banner is visible on first login', async ({ page }) => {
await goToDashboard(page);
// The onboarding banner should be visible (isFirstLogin=true initially)
await expect(page.getByText(/bienvenue sur classeo/i)).toBeVisible();
await expect(page.getByText(/score sérénité/i).first()).toBeVisible();
});
test('clicking serenity score opens explainer', async ({ page }) => {
await goToDashboard(page);
// Click the serenity score card
const scoreCard = page.locator('.serenity-card');
await expect(scoreCard).toBeVisible();
await scoreCard.click();
// The explainer modal/overlay should appear
// SerenityScoreExplainer should be visible after click
await expect(page.getByText(/cliquez pour en savoir plus/i)).toBeVisible();
});
});
// ============================================================================
// Teacher Dashboard View
// ============================================================================
test.describe('Teacher Dashboard', () => {
test('shows teacher dashboard header', async ({ page }) => {
await goToDashboard(page);
// Switch to teacher
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible();
await expect(page.getByText(/bienvenue.*voici vos outils du jour/i)).toBeVisible();
});
test('shows quick action cards', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByText(/faire l'appel/i)).toBeVisible();
await expect(page.getByText(/saisir des notes/i)).toBeVisible();
await expect(page.getByText(/créer un devoir/i)).toBeVisible();
});
test('quick action cards are disabled in demo mode', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
// Action cards should be disabled since hasRealData=false
const actionCards = page.locator('.action-card');
const count = await actionCards.count();
expect(count).toBeGreaterThanOrEqual(3);
for (let i = 0; i < count; i++) {
await expect(actionCards.nth(i)).toBeDisabled();
}
});
test('shows placeholder sections for teacher data', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByRole('heading', { name: /mes classes aujourd'hui/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /notes à saisir/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /appels du jour/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /statistiques rapides/i })).toBeVisible();
});
test('placeholder sections have informative messages', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByText(/vos classes apparaîtront ici/i)).toBeVisible();
await expect(page.getByText(/évaluations en attente de notation/i)).toBeVisible();
await expect(page.getByText(/les appels à effectuer/i)).toBeVisible();
await expect(page.getByText(/les statistiques de vos classes/i)).toBeVisible();
});
});
// ============================================================================
// Student Dashboard View
// ============================================================================
test.describe('Student Dashboard', () => {
test('shows student dashboard header', async ({ page }) => {
await goToDashboard(page);
// Switch to student
await switchToDemoRole(page, /Élève/i);
await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible();
// Student is minor by default, so "ton" instead of "votre"
await expect(page.getByText(/bienvenue.*voici ton tableau de bord/i)).toBeVisible();
});
test('shows info banner for student in demo mode', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, /Élève/i);
await expect(page.getByText(/ton emploi du temps, tes notes et tes devoirs/i)).toBeVisible();
});
test('shows placeholder sections for student data', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, /Élève/i);
await expect(page.getByRole('heading', { name: /mon emploi du temps/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /mes notes/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible();
});
test('placeholder sections show minor-appropriate messages', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, /Élève/i);
// Uses "ton/tes" for minors
await expect(page.getByText(/ton emploi du temps sera bientôt disponible/i)).toBeVisible();
await expect(page.getByText(/tes notes apparaîtront ici/i)).toBeVisible();
await expect(page.getByText(/tes devoirs s'afficheront ici/i)).toBeVisible();
});
});
// ============================================================================
// Admin Dashboard View
// ============================================================================
test.describe('Admin Dashboard', () => {
test('shows admin dashboard header', async ({ page }) => {
await goToDashboard(page);
// Switch to admin
await switchToDemoRole(page, 'Admin');
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible();
});
test('shows establishment name', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
// Demo data uses "École Alpha" as establishment name
await expect(page.getByText(/école alpha/i)).toBeVisible();
});
test('shows quick action links for admin', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
await expect(page.getByText(/gérer les utilisateurs/i)).toBeVisible();
await expect(page.getByText(/configurer les classes/i)).toBeVisible();
await expect(page.getByText(/gérer les matières/i)).toBeVisible();
await expect(page.getByText(/périodes scolaires/i)).toBeVisible();
await expect(page.getByText(/pédagogie/i)).toBeVisible();
});
test('admin quick action links have correct hrefs', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
// Verify action cards link to correct pages
const usersLink = page.locator('.action-card[href="/admin/users"]');
await expect(usersLink).toBeVisible();
const classesLink = page.locator('.action-card[href="/admin/classes"]');
await expect(classesLink).toBeVisible();
const subjectsLink = page.locator('.action-card[href="/admin/subjects"]');
await expect(subjectsLink).toBeVisible();
const periodsLink = page.locator('.action-card[href="/admin/academic-year/periods"]');
await expect(periodsLink).toBeVisible();
const pedagogyLink = page.locator('.action-card[href="/admin/pedagogy"]');
await expect(pedagogyLink).toBeVisible();
});
test('import action is disabled (bientot disponible)', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
await expect(page.getByText(/importer des données/i)).toBeVisible();
await expect(page.getByText(/bientôt disponible/i)).toBeVisible();
const importCard = page.locator('.action-card.disabled');
await expect(importCard).toBeVisible();
});
test('shows placeholder sections for admin stats', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
await expect(page.getByRole('heading', { name: /utilisateurs/i })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Configuration', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: /activité récente/i })).toBeVisible();
});
});
// ============================================================================
// Role Switching
// ============================================================================
test.describe('Role Switching', () => {
test('switching from parent to teacher changes dashboard content', async ({ page }) => {
await goToDashboard(page);
// Verify parent view
await expect(page.getByText(/score sérénité/i).first()).toBeVisible();
// Switch to teacher
await switchToDemoRole(page, 'Enseignant');
// Parent content should be gone
await expect(page.locator('.serenity-card')).not.toBeVisible();
// Teacher content should appear
await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible();
});
test('switching from teacher to student changes dashboard content', async ({ page }) => {
await goToDashboard(page);
// Switch to teacher first
await switchToDemoRole(page, 'Enseignant');
await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).toBeVisible();
// Switch to student
await switchToDemoRole(page, /Élève/i);
// Teacher content should be gone
await expect(page.getByRole('heading', { name: /tableau de bord enseignant/i })).not.toBeVisible();
// Student content should appear
await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible();
});
test('switching from student to admin changes dashboard content', async ({ page }) => {
await goToDashboard(page);
// Switch to student first
await switchToDemoRole(page, /Élève/i);
await expect(page.getByRole('heading', { name: /mon espace/i })).toBeVisible();
// Switch to admin
await switchToDemoRole(page, 'Admin');
// Student content should be gone
await expect(page.getByRole('heading', { name: /mon espace/i })).not.toBeVisible();
// Admin content should appear
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible();
});
test('active role button changes visual state', async ({ page }) => {
await goToDashboard(page);
// Parent should be active initially
const parentBtn = page.locator('.demo-controls button', { hasText: 'Parent' });
await expect(parentBtn).toHaveClass(/active/);
// Switch to teacher
await switchToDemoRole(page, 'Enseignant');
// Teacher should now be active, parent should not
const teacherBtn = page.locator('.demo-controls button', { hasText: 'Enseignant' });
await expect(teacherBtn).toHaveClass(/active/);
await expect(parentBtn).not.toHaveClass(/active/);
});
test('onboarding banner disappears after switching roles', async ({ page }) => {
await goToDashboard(page);
// Onboarding banner is visible initially (isFirstLogin=true)
await expect(page.getByText(/bienvenue sur classeo/i)).toBeVisible();
// Switch role - this calls switchDemoRole which sets isFirstLogin=false
await switchToDemoRole(page, 'Enseignant');
// Switch back to parent
await switchToDemoRole(page, 'Parent');
// Onboarding banner should no longer be visible
await expect(page.getByText(/bienvenue sur classeo/i)).not.toBeVisible();
});
});
// ============================================================================
// Admin Dashboard - Navigation from Quick Actions
// ============================================================================
test.describe('Admin Quick Action Navigation', () => {
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
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' }
);
});
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 });
}
test('clicking "Gerer les utilisateurs" navigates to users page', async ({ page }) => {
await loginAsAdmin(page);
// Admin dashboard should show after login (ROLE_ADMIN maps to admin view)
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
// Click users link
await page.locator('.action-card[href="/admin/users"]').click();
await expect(page).toHaveURL(/\/admin\/users/);
});
test('clicking "Configurer les classes" navigates to classes page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
await page.locator('.action-card[href="/admin/classes"]').click();
await expect(page).toHaveURL(/\/admin\/classes/);
});
test('clicking "Gerer les matieres" navigates to subjects page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
await page.locator('.action-card[href="/admin/subjects"]').click();
await expect(page).toHaveURL(/\/admin\/subjects/);
});
test('clicking "Periodes scolaires" navigates to periods page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
await page.locator('.action-card[href="/admin/academic-year/periods"]').click();
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
});
test('clicking "Pedagogie" navigates to pedagogy page', async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({ timeout: 10000 });
await page.locator('.action-card[href="/admin/pedagogy"]').click();
await expect(page).toHaveURL(/\/admin\/pedagogy/);
});
});
// ============================================================================
// Accessibility
// ============================================================================
test.describe('Accessibility', () => {
test('serenity score card has accessible label', async ({ page }) => {
await goToDashboard(page);
const scoreCard = page.locator('[aria-label*="Score Sérénité"]');
await expect(scoreCard).toBeVisible();
});
test('teacher quick actions have a visually hidden heading', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Enseignant');
// The "Actions rapides" heading exists but is sr-only
const actionsHeading = page.getByRole('heading', { name: /actions rapides/i });
await expect(actionsHeading).toBeAttached();
});
test('admin configuration actions have a visually hidden heading', async ({ page }) => {
await goToDashboard(page);
await switchToDemoRole(page, 'Admin');
const configHeading = page.getByRole('heading', { name: /actions de configuration/i });
await expect(configHeading).toBeAttached();
});
});
});