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');
});
});

View File

@@ -0,0 +1,194 @@
import { test, expect, type Page } 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-childselector-admin@example.com';
const ADMIN_PASSWORD = 'AdminCSTest123';
const PARENT_EMAIL = 'e2e-childselector-parent@example.com';
const PARENT_PASSWORD = 'ChildSelectorTest123';
const STUDENT1_EMAIL = 'e2e-childselector-student1@example.com';
const STUDENT1_PASSWORD = 'Student1Test123';
const STUDENT2_EMAIL = 'e2e-childselector-student2@example.com';
const STUDENT2_PASSWORD = 'Student2Test123';
let parentUserId: string;
let student1UserId: string;
let student2UserId: 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];
}
async function loginAsAdmin(page: 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 });
}
async function addGuardianIfNotLinked(page: Page, studentId: string, guardianId: string, relationship: string) {
await page.goto(`${ALPHA_URL}/admin/students/${studentId}`);
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(
page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
// Skip if add button is not visible (max guardians already linked)
const addButton = page.getByRole('button', { name: /ajouter un parent/i });
if (!(await addButton.isVisible())) return;
await addButton.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(relationship);
await dialog.getByRole('button', { name: 'Ajouter' }).click();
// Wait for either success (new link) or error (already linked → 409)
await expect(
page.locator('.alert-success').or(page.locator('.alert-error'))
).toBeVisible({ timeout: 10000 });
}
async function removeFirstGuardian(page: Page, studentId: string) {
await page.goto(`${ALPHA_URL}/admin/students/${studentId}`);
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(
page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
// Skip if no guardian to remove
if (!(await page.locator('.guardian-item').first().isVisible())) return;
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 });
}
test.describe('Child Selector', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
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 parent user
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 student 1
const student1Output = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT1_EMAIL} --password=${STUDENT1_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
student1UserId = extractUserId(student1Output);
// Create student 2
const student2Output = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
student2UserId = extractUserId(student2Output);
// Use admin UI to link parent to both students
const page = await browser.newPage();
await loginAsAdmin(page);
await addGuardianIfNotLinked(page, student1UserId, parentUserId, 'tuteur');
await addGuardianIfNotLinked(page, student2UserId, parentUserId, 'tutrice');
await page.close();
});
async function loginAsParent(page: Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
}
test('[P1] parent with multiple children should see child selector', async ({ page }) => {
await loginAsParent(page);
// ChildSelector should be visible when parent has 2+ children
const childSelector = page.locator('.child-selector');
await expect(childSelector).toBeVisible({ timeout: 10000 });
// Should display the label
await expect(childSelector.locator('.child-selector-label')).toHaveText('Enfant :');
// Should have 2 child buttons
const buttons = childSelector.locator('.child-button');
await expect(buttons).toHaveCount(2);
// First child should be auto-selected
await expect(buttons.first()).toHaveClass(/selected/);
});
test('[P1] parent can switch between children', async ({ page }) => {
await loginAsParent(page);
const childSelector = page.locator('.child-selector');
await expect(childSelector).toBeVisible({ timeout: 10000 });
const buttons = childSelector.locator('.child-button');
await expect(buttons).toHaveCount(2);
// First button should be selected initially
await expect(buttons.first()).toHaveClass(/selected/);
await expect(buttons.nth(1)).not.toHaveClass(/selected/);
// Click second button
await buttons.nth(1).click();
// Second button should now be selected, first should not
await expect(buttons.nth(1)).toHaveClass(/selected/);
await expect(buttons.first()).not.toHaveClass(/selected/);
});
test('[P1] parent with single child should see static child name', async ({ browser, page }) => {
// Remove one link via admin UI
const adminPage = await browser.newPage();
await loginAsAdmin(adminPage);
await removeFirstGuardian(adminPage, student2UserId);
await adminPage.close();
await loginAsParent(page);
// ChildSelector should be visible with 1 child (showing name, no buttons)
await expect(page.locator('.child-selector')).toBeVisible({ timeout: 5000 });
await expect(page.locator('.child-button')).toHaveCount(0);
// Restore the second link via admin UI for clean state
const restorePage = await browser.newPage();
await loginAsAdmin(restorePage);
await addGuardianIfNotLinked(restorePage, student2UserId, parentUserId, 'tutrice');
await restorePage.close();
});
});

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();
});
});
});

View 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 });
});
});

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 });
});
});

View File

@@ -55,11 +55,11 @@ test.describe('User Blocking', () => {
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await expect(targetRow).toBeVisible();
// Click "Bloquer" button
await targetRow.getByRole('button', { name: /bloquer/i }).click();
// Block modal should appear
await expect(page.locator('#block-modal-title')).toBeVisible();
// Click "Bloquer" button and wait for modal (retry handles hydration timing)
await expect(async () => {
await targetRow.getByRole('button', { name: /bloquer/i }).click();
await expect(page.locator('#block-modal-title')).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 10000 });
// Fill in the reason
await page.locator('#block-reason').fill('Comportement inapproprié en E2E');
@@ -110,7 +110,10 @@ test.describe('User Blocking', () => {
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
await targetRow.getByRole('button', { name: /bloquer/i }).click();
await expect(async () => {
await targetRow.getByRole('button', { name: /bloquer/i }).click();
await expect(page.locator('#block-modal-title')).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: 10000 });
await page.locator('#block-reason').fill('Bloqué pour test login');
await page.getByRole('button', { name: /confirmer le blocage/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 5000 });

View File

@@ -0,0 +1,81 @@
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-creation-admin@example.com';
const ADMIN_PASSWORD = 'CreationTest123';
const UNIQUE_SUFFIX = Date.now();
const INVITED_EMAIL = `e2e-invited-prof-${UNIQUE_SUFFIX}@example.com`;
test.describe('User Creation', () => {
test.describe.configure({ mode: 'serial' });
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('admin can invite a user with roles array', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
// Wait for users table or empty state to load
await expect(
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Click "Inviter un utilisateur"
await page.getByRole('button', { name: /inviter un utilisateur/i }).first().click();
// Modal should appear
await expect(page.locator('#modal-title')).toBeVisible();
await expect(page.locator('#modal-title')).toHaveText('Inviter un utilisateur');
// Fill in the form
await page.locator('#user-firstname').fill('Marie');
await page.locator('#user-lastname').fill('Curie');
await page.locator('#user-email').fill(INVITED_EMAIL);
// Select "Enseignant" role via checkbox (this sends roles[] without role singular)
await page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).click();
// Submit the form (target the modal's submit button specifically)
const modal = page.locator('.modal');
await modal.getByRole('button', { name: "Envoyer l'invitation" }).click();
// Verify success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(INVITED_EMAIL);
// Verify the user appears in the table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const newUserRow = page.locator('tr', { has: page.locator(`text=${INVITED_EMAIL}`) });
await expect(newUserRow).toBeVisible();
await expect(newUserRow).toContainText('Marie');
await expect(newUserRow).toContainText('Curie');
await expect(newUserRow).toContainText('Enseignant');
await expect(newUserRow).toContainText('En attente');
});
});

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
interface Child {
id: string;
studentId: string;
relationshipType: string;
relationshipLabel: string;
firstName: string;
lastName: string;
}
let {
onChildSelected
}: {
onChildSelected?: (childId: string) => void;
} = $props();
let children = $state<Child[]>([]);
let selectedChildId = $state<string | null>(null);
let isLoading = $state(true);
let error = $state<string | null>(null);
$effect(() => {
loadChildren();
});
async function loadChildren() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/children`);
if (!response.ok) {
throw new Error('Impossible de charger les enfants');
}
const data = await response.json();
children = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
const first = children[0];
if (first && !selectedChildId) {
selectedChildId = first.studentId;
onChildSelected?.(first.studentId);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function selectChild(childId: string) {
selectedChildId = childId;
onChildSelected?.(childId);
}
</script>
{#if isLoading}
<div class="child-selector-loading">
<div class="spinner"></div>
</div>
{:else if error}
<div class="child-selector-error">{error}</div>
{:else if children.length === 1}
{#each children as child}
<div class="child-selector">
<span class="child-selector-label">Enfant :</span>
<span class="child-name">{child.firstName} {child.lastName}</span>
</div>
{/each}
{:else if children.length > 1}
<div class="child-selector">
<span class="child-selector-label">Enfant :</span>
<div class="child-selector-buttons">
{#each children as child (child.id)}
<button
class="child-button"
class:selected={selectedChildId === child.studentId}
onclick={() => selectChild(child.studentId)}
>
{child.firstName} {child.lastName}
</button>
{/each}
</div>
</div>
{/if}
<style>
.child-selector {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.5rem;
}
.child-selector-label {
font-size: 0.875rem;
font-weight: 500;
color: #1e40af;
white-space: nowrap;
}
.child-name {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.child-selector-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.child-button {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.child-button:hover {
background: #f3f4f6;
}
.child-button.selected {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.child-selector-loading {
display: flex;
justify-content: center;
padding: 0.5rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.child-selector-error {
padding: 0.5rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
color: #991b1b;
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,521 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
interface Guardian {
id: string;
guardianId: string;
relationshipType: string;
relationshipLabel: string;
linkedAt: string;
firstName: string;
lastName: string;
email: string;
}
const RELATIONSHIP_OPTIONS = [
{ value: 'père', label: 'Père' },
{ value: 'mère', label: 'Mère' },
{ value: 'tuteur', label: 'Tuteur' },
{ value: 'tutrice', label: 'Tutrice' },
{ value: 'grand-père', label: 'Grand-père' },
{ value: 'grand-mère', label: 'Grand-mère' },
{ value: 'autre', label: 'Autre' }
];
let {
studentId
}: {
studentId: string;
} = $props();
let guardians = $state<Guardian[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Add guardian modal
let showAddModal = $state(false);
let newGuardianId = $state('');
let newRelationshipType = $state('autre');
let isSubmitting = $state(false);
// Confirm remove
let confirmRemoveId = $state<string | null>(null);
let isRemoving = $state(false);
$effect(() => {
loadGuardians();
});
async function loadGuardians() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/guardians`);
if (!response.ok) {
throw new Error('Erreur lors du chargement des parents');
}
const data = await response.json();
guardians = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
async function addGuardian() {
if (!newGuardianId.trim()) return;
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/guardians`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guardianId: newGuardianId,
relationshipType: newRelationshipType
})
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.detail ?? data?.message ?? 'Erreur lors de l\'ajout du parent');
}
successMessage = 'Parent ajouté avec succès';
showAddModal = false;
newGuardianId = '';
newRelationshipType = 'autre';
await loadGuardians();
globalThis.setTimeout(() => { successMessage = null; }, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isSubmitting = false;
}
}
async function removeGuardian(guardianId: string) {
try {
isRemoving = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/students/${studentId}/guardians/${guardianId}`,
{ method: 'DELETE' }
);
if (!response.ok) {
throw new Error('Erreur lors de la suppression de la liaison');
}
successMessage = 'Liaison supprimée';
confirmRemoveId = null;
await loadGuardians();
globalThis.setTimeout(() => { successMessage = null; }, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isRemoving = false;
}
}
</script>
<section class="guardian-section">
<div class="section-header">
<h3>Parents / Tuteurs</h3>
{#if guardians.length < 2}
<button class="btn-add" onclick={() => { showAddModal = true; }}>
+ Ajouter un parent
</button>
{/if}
</div>
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if successMessage}
<div class="alert alert-success">{successMessage}</div>
{/if}
{#if isLoading}
<div class="loading">Chargement des parents...</div>
{:else if guardians.length === 0}
<p class="empty-state">Aucun parent/tuteur lié à cet élève.</p>
{:else}
<ul class="guardian-list">
{#each guardians as guardian (guardian.id)}
<li class="guardian-item">
<div class="guardian-info">
<span class="guardian-name">{guardian.firstName} {guardian.lastName}</span>
<span class="guardian-type">{guardian.relationshipLabel}</span>
<span class="guardian-email">{guardian.email}</span>
<span class="guardian-date">
Lié le {new Date(guardian.linkedAt).toLocaleDateString('fr-FR')}
</span>
</div>
<div class="guardian-actions">
{#if confirmRemoveId === guardian.guardianId}
<span class="confirm-text">Confirmer ?</span>
<button
class="btn-confirm-remove"
onclick={() => removeGuardian(guardian.guardianId)}
disabled={isRemoving}
>
{isRemoving ? '...' : 'Oui'}
</button>
<button class="btn-cancel" onclick={() => { confirmRemoveId = null; }}>
Non
</button>
{:else}
<button
class="btn-remove"
onclick={() => { confirmRemoveId = guardian.guardianId; }}
>
Retirer
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
{#if showAddModal}
<div class="modal-overlay" onclick={() => { showAddModal = false; }} role="presentation">
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<header class="modal-header">
<h2>Ajouter un parent/tuteur</h2>
<button class="modal-close" onclick={() => { showAddModal = false; }}>&times;</button>
</header>
<form
class="modal-body"
onsubmit={(e) => { e.preventDefault(); addGuardian(); }}
>
<div class="form-group">
<label for="guardianId">ID du parent</label>
<input
id="guardianId"
type="text"
bind:value={newGuardianId}
placeholder="UUID du compte parent"
required
/>
</div>
<div class="form-group">
<label for="relationshipType">Type de relation</label>
<select id="relationshipType" bind:value={newRelationshipType}>
{#each RELATIONSHIP_OPTIONS as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={() => { showAddModal = false; }}>
Annuler
</button>
<button type="submit" class="btn-primary" disabled={isSubmitting || !newGuardianId.trim()}>
{isSubmitting ? 'Ajout...' : 'Ajouter'}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.guardian-section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.btn-add {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-add:hover {
background: #2563eb;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.loading {
text-align: center;
color: #6b7280;
padding: 1rem;
}
.empty-state {
color: #6b7280;
font-size: 0.875rem;
text-align: center;
padding: 1rem;
}
.guardian-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.guardian-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
gap: 1rem;
}
.guardian-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.guardian-name {
font-weight: 600;
color: #1f2937;
}
.guardian-type {
font-size: 0.75rem;
color: #6b7280;
}
.guardian-email {
font-size: 0.75rem;
color: #6b7280;
}
.guardian-date {
font-size: 0.75rem;
color: #9ca3af;
}
.guardian-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.confirm-text {
font-size: 0.875rem;
color: #991b1b;
font-weight: 500;
}
.btn-remove {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-remove:hover {
background: #fee2e2;
}
.btn-confirm-remove {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #dc2626;
border: none;
color: white;
border-radius: 0.25rem;
cursor: pointer;
}
.btn-confirm-remove:disabled {
opacity: 0.5;
}
.btn-cancel {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
color: #374151;
border-radius: 0.25rem;
cursor: pointer;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 100%;
max-width: 28rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
line-height: 1;
}
.modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.form-group input,
.form-group select {
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.5rem;
}
.btn-primary {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
background: white;
border: 1px solid #d1d5db;
color: #374151;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover {
background: #f3f4f6;
}
</style>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { page } from '$app/stores';
import GuardianList from '$lib/components/organisms/GuardianList/GuardianList.svelte';
let studentId = $derived($page.params.id ?? '');
</script>
<svelte:head>
<title>Fiche élève - Classeo</title>
</svelte:head>
<div class="student-detail">
<header class="page-header">
<a href="/admin/users" class="back-link">&larr; Retour</a>
<h1>Fiche élève</h1>
</header>
<GuardianList {studentId} />
</div>
<style>
.student-detail {
max-width: 48rem;
margin: 0 auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.back-link {
font-size: 0.875rem;
color: #6b7280;
text-decoration: none;
width: fit-content;
}
.back-link:hover {
color: #3b82f6;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
</style>

View File

@@ -5,6 +5,7 @@
import DashboardTeacher from '$lib/components/organisms/Dashboard/DashboardTeacher.svelte';
import DashboardStudent from '$lib/components/organisms/Dashboard/DashboardStudent.svelte';
import DashboardAdmin from '$lib/components/organisms/Dashboard/DashboardAdmin.svelte';
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
import { getActiveRole, getIsLoading } from '$features/roles/roleContext.svelte';
type DashboardView = 'parent' | 'teacher' | 'student' | 'admin';
@@ -42,8 +43,15 @@
// Use demo data for now (no real data available yet)
const hasRealData = false;
// Selected child for parent dashboard (will drive data fetching when real API is connected)
let _selectedChildId = $state<string | null>(null);
// Demo child name for personalized messages
const childName = 'Emma';
let childName = $state('Emma');
function handleChildSelected(childId: string) {
_selectedChildId = childId;
}
function handleToggleSerenity(enabled: boolean) {
serenityEnabled = enabled;
@@ -81,6 +89,9 @@
{/if}
{#if dashboardView === 'parent'}
{#if hasRoleContext}
<ChildSelector onChildSelected={handleChildSelected} />
{/if}
<DashboardParent
demoData={typedDemoData}
{isFirstLogin}

View File

@@ -0,0 +1,698 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* Unit tests for the auth service (auth.svelte.ts).
*
* The auth module uses Svelte 5 $state runes, so we test it through
* its public exported API. We mock global fetch and SvelteKit modules
* to isolate the auth logic.
*/
// Mock $app/navigation before importing the module
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
// Mock $lib/api (getApiBaseUrl)
vi.mock('$lib/api', () => ({
getApiBaseUrl: () => 'http://test.classeo.local:18000/api'
}));
// Helper: Create a valid-looking JWT token with a given payload
function createTestJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
const signature = 'test-signature';
return `${header}.${body}.${signature}`;
}
// Helper: Create a JWT with base64url encoding (- and _ instead of + and /)
function createTestJwtUrlSafe(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const body = btoa(JSON.stringify(payload))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const signature = 'test-signature';
return `${header}.${body}.${signature}`;
}
describe('auth service', () => {
let authModule: typeof import('$lib/auth/auth.svelte');
const mockGoto = vi.fn();
beforeEach(async () => {
vi.clearAllMocks();
vi.stubGlobal('fetch', vi.fn());
// Re-mock goto for each test
const navModule = await import('$app/navigation');
(navModule.goto as ReturnType<typeof vi.fn>).mockImplementation(mockGoto);
// Fresh import to reset $state
vi.resetModules();
authModule = await import('$lib/auth/auth.svelte');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ==========================================================================
// isAuthenticated / getAccessToken / getCurrentUserId
// ==========================================================================
describe('initial state', () => {
it('should not be authenticated initially', () => {
expect(authModule.isAuthenticated()).toBe(false);
});
it('should return null access token initially', () => {
expect(authModule.getAccessToken()).toBeNull();
});
it('should return null user ID initially', () => {
expect(authModule.getCurrentUserId()).toBeNull();
});
});
// ==========================================================================
// login
// ==========================================================================
describe('login', () => {
it('should return success and set token on successful login', async () => {
const token = createTestJwt({
sub: 'user@example.com',
user_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
});
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
const result = await authModule.login({
email: 'user@example.com',
password: 'password123'
});
expect(result.success).toBe(true);
expect(result.error).toBeUndefined();
expect(authModule.isAuthenticated()).toBe(true);
expect(authModule.getAccessToken()).toBe(token);
expect(authModule.getCurrentUserId()).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
});
it('should send credentials with correct format', async () => {
const token = createTestJwt({ sub: 'test@example.com', user_id: 'test-id' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({
email: 'test@example.com',
password: 'mypassword',
captcha_token: 'captcha123'
});
expect(fetch).toHaveBeenCalledWith(
'http://test.classeo.local:18000/api/login',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'test@example.com',
password: 'mypassword',
captcha_token: 'captcha123'
}),
credentials: 'include'
})
);
});
it('should return invalid_credentials error on 401', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 401,
json: () => Promise.resolve({
type: '/errors/authentication',
detail: 'Identifiants incorrects',
attempts: 2,
delay: 1,
captchaRequired: false
})
});
const result = await authModule.login({
email: 'user@example.com',
password: 'wrong'
});
expect(result.success).toBe(false);
expect(result.error?.type).toBe('invalid_credentials');
expect(result.error?.message).toBe('Identifiants incorrects');
expect(result.error?.attempts).toBe(2);
expect(result.error?.delay).toBe(1);
expect(authModule.isAuthenticated()).toBe(false);
});
it('should return rate_limited error on 429', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 429,
json: () => Promise.resolve({
type: '/errors/rate-limited',
detail: 'Trop de tentatives',
retryAfter: 60
})
});
const result = await authModule.login({
email: 'user@example.com',
password: 'password'
});
expect(result.success).toBe(false);
expect(result.error?.type).toBe('rate_limited');
expect(result.error?.retryAfter).toBe(60);
});
it('should return captcha_required error on 428', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 428,
json: () => Promise.resolve({
type: '/errors/captcha-required',
detail: 'CAPTCHA requis',
attempts: 5,
captchaRequired: true
})
});
const result = await authModule.login({
email: 'user@example.com',
password: 'password'
});
expect(result.success).toBe(false);
expect(result.error?.type).toBe('captcha_required');
expect(result.error?.captchaRequired).toBe(true);
});
it('should return account_suspended error on 403', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 403,
json: () => Promise.resolve({
type: '/errors/account-suspended',
detail: 'Votre compte a été suspendu'
})
});
const result = await authModule.login({
email: 'suspended@example.com',
password: 'password'
});
expect(result.success).toBe(false);
expect(result.error?.type).toBe('account_suspended');
expect(result.error?.message).toBe('Votre compte a été suspendu');
});
it('should return captcha_invalid error on 400 with captcha-invalid type', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 400,
json: () => Promise.resolve({
type: '/errors/captcha-invalid',
detail: 'CAPTCHA invalide',
captchaRequired: true
})
});
const result = await authModule.login({
email: 'user@example.com',
password: 'password',
captcha_token: 'invalid-captcha'
});
expect(result.success).toBe(false);
expect(result.error?.type).toBe('captcha_invalid');
expect(result.error?.captchaRequired).toBe(true);
});
it('should return unknown error when fetch throws', async () => {
(fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const result = await authModule.login({
email: 'user@example.com',
password: 'password'
});
expect(result.success).toBe(false);
expect(result.error?.type).toBe('unknown');
expect(result.error?.message).toContain('Erreur de connexion');
});
it('should extract user_id from JWT on successful login', async () => {
const userId = 'b2c3d4e5-f6a7-8901-bcde-f23456789012';
const token = createTestJwt({
sub: 'user@test.com',
user_id: userId
});
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
expect(authModule.getCurrentUserId()).toBe(userId);
});
it('should handle JWT with base64url encoding', async () => {
const userId = 'c3d4e5f6-a7b8-9012-cdef-345678901234';
const token = createTestJwtUrlSafe({
sub: 'urlsafe@test.com',
user_id: userId
});
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'urlsafe@test.com', password: 'pass' });
expect(authModule.getCurrentUserId()).toBe(userId);
});
it('should set currentUserId to null when token has no user_id claim', async () => {
const token = createTestJwt({
sub: 'user@test.com'
// no user_id claim
});
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Token is set but user ID extraction should return null
expect(authModule.isAuthenticated()).toBe(true);
expect(authModule.getCurrentUserId()).toBeNull();
});
});
// ==========================================================================
// refreshToken
// ==========================================================================
describe('refreshToken', () => {
it('should set new token on successful refresh', async () => {
const newToken = createTestJwt({
sub: 'user@test.com',
user_id: 'refresh-user-id'
});
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: newToken })
});
const result = await authModule.refreshToken();
expect(result).toBe(true);
expect(authModule.isAuthenticated()).toBe(true);
expect(authModule.getCurrentUserId()).toBe('refresh-user-id');
});
it('should clear token on failed refresh', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 401
});
const result = await authModule.refreshToken();
expect(result).toBe(false);
expect(authModule.isAuthenticated()).toBe(false);
expect(authModule.getCurrentUserId()).toBeNull();
});
it('should retry on 409 conflict (multi-tab race condition)', async () => {
const newToken = createTestJwt({
sub: 'user@test.com',
user_id: 'retry-user-id'
});
// First call returns 409 (token already rotated)
(fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: false,
status: 409
})
// Second call succeeds with new cookie
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: newToken })
});
const result = await authModule.refreshToken();
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledTimes(2);
expect(authModule.getCurrentUserId()).toBe('retry-user-id');
});
it('should fail after max retries on repeated 409', async () => {
// Three consecutive 409s (max retries is 2)
(fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: false, status: 409 })
.mockResolvedValueOnce({ ok: false, status: 409 })
.mockResolvedValueOnce({ ok: false, status: 409 });
const result = await authModule.refreshToken();
expect(result).toBe(false);
expect(fetch).toHaveBeenCalledTimes(3);
});
it('should clear state on network error during refresh', async () => {
(fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
const result = await authModule.refreshToken();
expect(result).toBe(false);
expect(authModule.isAuthenticated()).toBe(false);
expect(authModule.getCurrentUserId()).toBeNull();
});
it('should send refresh request with correct format', async () => {
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 401
});
await authModule.refreshToken();
expect(fetch).toHaveBeenCalledWith(
'http://test.classeo.local:18000/api/token/refresh',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
credentials: 'include'
})
);
});
});
// ==========================================================================
// logout
// ==========================================================================
describe('logout', () => {
it('should clear token and redirect to login', async () => {
// First login to set token
const token = createTestJwt({ sub: 'user@test.com', user_id: 'logout-user' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
expect(authModule.isAuthenticated()).toBe(true);
// Now logout
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
await authModule.logout();
expect(authModule.isAuthenticated()).toBe(false);
expect(authModule.getAccessToken()).toBeNull();
expect(authModule.getCurrentUserId()).toBeNull();
expect(mockGoto).toHaveBeenCalledWith('/login');
});
it('should still clear local state even if API call fails', async () => {
// Login first
const token = createTestJwt({ sub: 'user@test.com', user_id: 'logout-user' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Logout with API failure
(fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
await authModule.logout();
expect(authModule.isAuthenticated()).toBe(false);
expect(authModule.getAccessToken()).toBeNull();
expect(mockGoto).toHaveBeenCalledWith('/login');
});
it('should call onLogout callback when registered', async () => {
const logoutCallback = vi.fn();
authModule.onLogout(logoutCallback);
// Login first
const token = createTestJwt({ sub: 'user@test.com', user_id: 'callback-user' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Logout
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
await authModule.logout();
expect(logoutCallback).toHaveBeenCalledOnce();
});
});
// ==========================================================================
// authenticatedFetch
// ==========================================================================
describe('authenticatedFetch', () => {
it('should add Authorization header with Bearer token', async () => {
// Login to set token
const token = createTestJwt({ sub: 'user@test.com', user_id: 'auth-fetch-user' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Make authenticated request
const mockResponse = { ok: true, status: 200 };
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(mockResponse);
await authModule.authenticatedFetch('http://test.classeo.local:18000/api/users');
// Second call should be the authenticated request (first was login)
const calls = (fetch as ReturnType<typeof vi.fn>).mock.calls;
expect(calls.length).toBeGreaterThanOrEqual(2);
const lastCall = calls[1]!;
expect(lastCall[0]).toBe('http://test.classeo.local:18000/api/users');
const headers = lastCall[1]?.headers as Headers;
expect(headers).toBeDefined();
// Headers is a Headers object
expect(headers.get('Authorization')).toBe(`Bearer ${token}`);
});
it('should attempt refresh when no token is available', async () => {
// No login - token is null
// First fetch call will be the refresh attempt
const refreshToken = createTestJwt({ sub: 'user@test.com', user_id: 'refreshed-user' });
(fetch as ReturnType<typeof vi.fn>)
// Refresh call succeeds
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: refreshToken })
})
// Then the actual request succeeds
.mockResolvedValueOnce({ ok: true, status: 200 });
await authModule.authenticatedFetch('http://test.classeo.local:18000/api/data');
// Should have made 2 calls: refresh + actual request
expect(fetch).toHaveBeenCalledTimes(2);
});
it('should retry with refresh on 401 response', async () => {
// Login first
const oldToken = createTestJwt({ sub: 'user@test.com', user_id: 'old-user' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: oldToken })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Request returns 401
const newToken = createTestJwt({ sub: 'user@test.com', user_id: 'new-user' });
(fetch as ReturnType<typeof vi.fn>)
// First request returns 401
.mockResolvedValueOnce({ ok: false, status: 401 })
// Refresh succeeds
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: newToken })
})
// Retried request succeeds
.mockResolvedValueOnce({ ok: true, status: 200 });
const response = await authModule.authenticatedFetch('http://test.classeo.local:18000/api/data');
expect(response.ok).toBe(true);
});
it('should redirect to login if refresh fails during 401 retry', async () => {
// Login first
const token = createTestJwt({ sub: 'user@test.com', user_id: 'expired-user' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Request returns 401 and refresh also fails
(fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: false, status: 401 });
await expect(
authModule.authenticatedFetch('http://test.classeo.local:18000/api/data')
).rejects.toThrow('Session expired');
expect(mockGoto).toHaveBeenCalledWith('/login');
});
});
// ==========================================================================
// JWT edge cases (tested through login)
// ==========================================================================
describe('JWT parsing edge cases', () => {
it('should handle token with non-string user_id', async () => {
// user_id is a number instead of string
const token = createTestJwt({
sub: 'user@test.com',
user_id: 12345
});
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Should return null because user_id is not a string
expect(authModule.getCurrentUserId()).toBeNull();
// But token should still be set
expect(authModule.isAuthenticated()).toBe(true);
});
it('should handle token with empty payload', async () => {
const token = createTestJwt({});
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
expect(authModule.getCurrentUserId()).toBeNull();
expect(authModule.isAuthenticated()).toBe(true);
});
it('should handle malformed token (not 3 parts)', async () => {
const malformedToken = 'not.a.valid.jwt.token';
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: malformedToken })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Token is stored but user ID extraction fails
expect(authModule.isAuthenticated()).toBe(true);
expect(authModule.getCurrentUserId()).toBeNull();
});
it('should handle token with invalid base64 payload', async () => {
const invalidToken = 'header.!!!invalid-base64!!!.signature';
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: invalidToken })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
expect(authModule.isAuthenticated()).toBe(true);
expect(authModule.getCurrentUserId()).toBeNull();
});
it('should handle token with valid base64 but invalid JSON', async () => {
const header = btoa(JSON.stringify({ alg: 'HS256' }));
const body = btoa('not-json-content');
const invalidJsonToken = `${header}.${body}.signature`;
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token: invalidJsonToken })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
expect(authModule.isAuthenticated()).toBe(true);
expect(authModule.getCurrentUserId()).toBeNull();
});
});
// ==========================================================================
// onLogout callback
// ==========================================================================
describe('onLogout', () => {
it('should allow registering a logout callback', () => {
const callback = vi.fn();
// Should not throw
authModule.onLogout(callback);
});
it('should invoke callback before clearing state during logout', async () => {
let wasAuthenticatedDuringCallback = false;
const callback = vi.fn(() => {
// Check auth state at the moment the callback fires
wasAuthenticatedDuringCallback = authModule.isAuthenticated();
});
authModule.onLogout(callback);
// Login
const token = createTestJwt({ sub: 'user@test.com', user_id: 'cb-user' });
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ token })
});
await authModule.login({ email: 'user@test.com', password: 'pass' });
// Logout
(fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
await authModule.logout();
expect(callback).toHaveBeenCalledOnce();
// The callback fires before accessToken is set to null
expect(wasAuthenticatedDuringCallback).toBe(true);
});
});
});