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:
116
frontend/e2e/activation-parent-link.spec.ts
Normal file
116
frontend/e2e/activation-parent-link.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
194
frontend/e2e/child-selector.spec.ts
Normal file
194
frontend/e2e/child-selector.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
235
frontend/e2e/guardian-management.spec.ts
Normal file
235
frontend/e2e/guardian-management.spec.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
// Test credentials
|
||||
const ADMIN_EMAIL = 'e2e-guardian-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'GuardianTest123';
|
||||
const STUDENT_EMAIL = 'e2e-guardian-student@example.com';
|
||||
const STUDENT_PASSWORD = 'StudentTest123';
|
||||
const PARENT_EMAIL = 'e2e-guardian-parent@example.com';
|
||||
const PARENT_PASSWORD = 'ParentTest123';
|
||||
const PARENT2_EMAIL = 'e2e-guardian-parent2@example.com';
|
||||
const PARENT2_PASSWORD = 'Parent2Test123';
|
||||
|
||||
let studentUserId: string;
|
||||
let parentUserId: string;
|
||||
let parent2UserId: string;
|
||||
|
||||
/**
|
||||
* Extracts the User ID from the Symfony console table output.
|
||||
*
|
||||
* The create-test-user command outputs a table like:
|
||||
* | Property | Value |
|
||||
* | User ID | a1b2c3d4-e5f6-7890-abcd-ef1234567890 |
|
||||
*/
|
||||
function extractUserId(output: string): string {
|
||||
const match = output.match(/User ID\s+([a-f0-9-]{36})/i);
|
||||
if (!match) {
|
||||
throw new Error(`Could not extract User ID from command output:\n${output}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test.describe('Guardian Management', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
// Create admin user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Create student user and capture userId
|
||||
const studentOutput = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
studentUserId = extractUserId(studentOutput);
|
||||
|
||||
// Create first parent user and capture userId
|
||||
const parentOutput = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
parentUserId = extractUserId(parentOutput);
|
||||
|
||||
// Create second parent user for the max guardians test
|
||||
const parent2Output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT2_EMAIL} --password=${PARENT2_PASSWORD} --role=ROLE_PARENT 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
parent2UserId = extractUserId(parent2Output);
|
||||
|
||||
// Clean up any existing guardian links for this student (DB + cache)
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM student_guardians WHERE student_id = '${studentUserId}'" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear student_guardians.cache --env=dev 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Ignore cleanup errors -- table may not have data yet
|
||||
}
|
||||
});
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /se connecter/i }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the guardian section to be fully hydrated (client-side JS loaded).
|
||||
*
|
||||
* The server renders the section with a "Chargement..." indicator. Only after
|
||||
* client-side hydration does the $effect() fire, triggering loadGuardians().
|
||||
* When that completes, either the empty-state or the guardian-list appears.
|
||||
* Waiting for one of these ensures the component is interactive.
|
||||
*/
|
||||
async function waitForGuardianSection(page: import('@playwright/test').Page) {
|
||||
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.getByText(/aucun parent\/tuteur lié/i)
|
||||
.or(page.locator('.guardian-list'))
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the add-guardian dialog, fills the form, and submits.
|
||||
* Waits for the success message before returning.
|
||||
*/
|
||||
async function addGuardianViaDialog(
|
||||
page: import('@playwright/test').Page,
|
||||
guardianId: string,
|
||||
relationshipType: string
|
||||
) {
|
||||
await page.getByRole('button', { name: /ajouter un parent/i }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await dialog.getByLabel(/id du parent/i).fill(guardianId);
|
||||
await dialog.getByLabel(/type de relation/i).selectOption(relationshipType);
|
||||
await dialog.getByRole('button', { name: 'Ajouter' }).click();
|
||||
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the first guardian in the list using the two-step confirmation.
|
||||
* Waits for the success message before returning.
|
||||
*/
|
||||
async function removeFirstGuardian(page: import('@playwright/test').Page) {
|
||||
const guardianItem = page.locator('.guardian-item').first();
|
||||
await expect(guardianItem).toBeVisible();
|
||||
await guardianItem.getByRole('button', { name: /retirer/i }).click();
|
||||
|
||||
await expect(guardianItem.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
|
||||
await guardianItem.getByRole('button', { name: /oui/i }).click();
|
||||
|
||||
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
test('[P1] should display empty guardian list for student with no guardians', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
||||
|
||||
await waitForGuardianSection(page);
|
||||
|
||||
// Should show the empty state since no guardians are linked
|
||||
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible();
|
||||
|
||||
// The "add guardian" button should be visible
|
||||
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[P1] should link a guardian to a student', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
||||
|
||||
await waitForGuardianSection(page);
|
||||
|
||||
// Add the guardian via the dialog
|
||||
await addGuardianViaDialog(page, parentUserId, 'père');
|
||||
|
||||
// Verify success message
|
||||
await expect(page.locator('.alert-success')).toContainText(/parent ajouté/i);
|
||||
|
||||
// The guardian list should now contain the new item
|
||||
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
|
||||
const guardianItem = page.locator('.guardian-item').first();
|
||||
await expect(guardianItem).toBeVisible();
|
||||
await expect(guardianItem).toContainText('Père');
|
||||
|
||||
// Empty state should no longer be visible
|
||||
await expect(page.getByText(/aucun parent\/tuteur lié/i)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[P1] should unlink a guardian from a student', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
||||
|
||||
await waitForGuardianSection(page);
|
||||
|
||||
// Wait for the guardian list to be loaded (from previous test)
|
||||
await expect(page.locator('.guardian-list')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Remove the first guardian using the two-step confirmation
|
||||
await removeFirstGuardian(page);
|
||||
|
||||
// Verify success message
|
||||
await expect(page.locator('.alert-success')).toContainText(/liaison supprimée/i);
|
||||
|
||||
// The empty state should return since the only guardian was removed
|
||||
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('[P2] should not show add button when maximum guardians reached', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
|
||||
|
||||
await waitForGuardianSection(page);
|
||||
|
||||
// Link first guardian (père)
|
||||
await addGuardianViaDialog(page, parentUserId, 'père');
|
||||
|
||||
// Wait for the add button to still be available after first link
|
||||
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Link second guardian (mère)
|
||||
await addGuardianViaDialog(page, parent2UserId, 'mère');
|
||||
|
||||
// Now with 2 guardians linked, the add button should NOT be visible
|
||||
await expect(page.getByRole('button', { name: /ajouter un parent/i })).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify both guardian items are displayed
|
||||
await expect(page.locator('.guardian-item')).toHaveCount(2);
|
||||
|
||||
// Clean up: remove both guardians so the state is clean for potential re-runs
|
||||
await removeFirstGuardian(page);
|
||||
await expect(page.locator('.guardian-item')).toHaveCount(1, { timeout: 5000 });
|
||||
await removeFirstGuardian(page);
|
||||
|
||||
// Verify empty state returns
|
||||
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
385
frontend/e2e/students.spec.ts
Normal file
385
frontend/e2e/students.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
81
frontend/e2e/user-creation.spec.ts
Normal file
81
frontend/e2e/user-creation.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user