Les tests E2E échouaient pour trois raisons principales : 1. Initialisation asynchrone TipTap — L'éditeur rich-text s'initialise via des imports dynamiques dans onMount(). Les tests interagissaient avec .rich-text-content avant que l'élément n'existe dans le DOM. Ajout d'attentes explicites avant chaque interaction avec l'éditeur. 2. Pollution inter-tests — Les fonctions de nettoyage (classes, subjects) ne supprimaient pas les tables dépendantes (homework, evaluations, schedule_slots), provoquant des erreurs FK silencieuses dans les try/catch. De plus, homework_submissions n'a pas de ON DELETE CASCADE sur homework_id, nécessitant une suppression explicite. 3. État partagé du tenant — Les règles de devoirs (homework_rules) et le calendrier scolaire (school_calendar_entries avec Vacances de Printemps) persistaient entre les fichiers de test, bloquant la création de devoirs dans des tests non liés aux règles.
453 lines
18 KiB
TypeScript
453 lines
18 KiB
TypeScript
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';
|
|
const PARENT_FIRST_NAME = 'StudParent';
|
|
const PARENT_LAST_NAME = 'TestLink';
|
|
|
|
let studentUserId: 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');
|
|
|
|
// Clear rate limiter to prevent login throttling across serial tests
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch {
|
|
// Cache pool may not exist
|
|
}
|
|
|
|
// 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 (with name for search-based linking)
|
|
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 --firstName=${PARENT_FIRST_NAME} --lastName=${PARENT_LAST_NAME} 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
|
|
// 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
|
|
}
|
|
});
|
|
|
|
test.beforeEach(async () => {
|
|
const projectRoot = join(__dirname, '../..');
|
|
const composeFile = join(projectRoot, 'compose.yaml');
|
|
try {
|
|
execSync(
|
|
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`,
|
|
{ encoding: 'utf-8' }
|
|
);
|
|
} catch {
|
|
// Cache pool may not exist
|
|
}
|
|
});
|
|
|
|
// 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 Promise.all([
|
|
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
|
page.getByRole('button', { name: /se connecter/i }).click()
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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: 15000 });
|
|
await expect(
|
|
page.getByText(/aucun parent\/tuteur lié/i)
|
|
.or(page.locator('.guardian-list'))
|
|
).toBeVisible({ timeout: 15000 });
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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.getByRole('heading', { name: /fiche élève/i })).toBeVisible({ timeout: 10000 });
|
|
await expect(page).toHaveTitle(/fiche élève/i);
|
|
});
|
|
|
|
test('back link navigates to students 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 students page
|
|
await expect(page).toHaveURL(/\/admin\/students/);
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// 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);
|
|
|
|
// Clean up guardians if any exist (cross-browser interference: parallel
|
|
// browser projects share the same DB, so another browser may have added
|
|
// guardians between our beforeAll cleanup and this test).
|
|
while (await page.locator('.guardian-item').count() > 0) {
|
|
const item = page.locator('.guardian-item').first();
|
|
await item.getByRole('button', { name: /retirer/i }).click();
|
|
await expect(item.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
|
|
await item.getByRole('button', { name: /oui/i }).click();
|
|
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
// Should show the empty state
|
|
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 15000 });
|
|
|
|
// 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.getByRole('combobox', { name: /rechercher/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 });
|
|
|
|
// Search for the parent by name
|
|
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
|
|
await searchInput.fill(PARENT_EMAIL);
|
|
|
|
// Wait for autocomplete results
|
|
const listbox = dialog.locator('#parent-search-listbox');
|
|
await expect(listbox).toBeVisible({ timeout: 10000 });
|
|
const option = listbox.locator('[role="option"]').first();
|
|
await option.click();
|
|
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
|
|
|
|
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 });
|
|
|
|
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
|
|
await searchInput.fill(PARENT_EMAIL);
|
|
|
|
const listbox2 = dialog.locator('#parent-search-listbox');
|
|
await expect(listbox2).toBeVisible({ timeout: 10000 });
|
|
const option = listbox2.locator('[role="option"]').first();
|
|
await option.click();
|
|
|
|
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 });
|
|
|
|
// Search for the student user (pagination may hide them beyond page 1)
|
|
const searchInput = page.locator('input[type="search"]');
|
|
await searchInput.fill(STUDENT_EMAIL);
|
|
await page.waitForTimeout(500);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// 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 });
|
|
|
|
// Search for the student user (pagination may hide them beyond page 1)
|
|
const searchInput = page.locator('input[type="search"]');
|
|
await searchInput.fill(STUDENT_EMAIL);
|
|
await page.waitForTimeout(500);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// 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 });
|
|
});
|
|
});
|