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-classes-admin@example.com'; const ADMIN_PASSWORD = 'ClassesTest123'; // Force serial execution to ensure Empty State runs first test.describe.configure({ mode: 'serial' }); test.describe('Classes Management (Story 2.1)', () => { // Create admin user and clean up classes before running tests test.beforeAll(async () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); try { // 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' } ); console.log('Classes E2E test admin user created'); // Clean up all classes for this tenant to ensure Empty State test works execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, { encoding: 'utf-8' } ); console.log('Classes cleaned up for E2E tests'); } catch (error) { console.error('Setup error:', error); } }); // 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 }); } // Helper to open "Nouvelle classe" dialog with proper wait async function openNewClassDialog(page: import('@playwright/test').Page) { const button = page.getByRole('button', { name: /nouvelle classe/i }); await button.waitFor({ state: 'visible' }); // Wait for any pending network requests to finish before clicking await page.waitForLoadState('networkidle'); // Click the button await button.click(); // Wait for dialog to appear - retry click if needed (webkit timing issue) const dialog = page.getByRole('dialog'); try { await expect(dialog).toBeVisible({ timeout: 5000 }); } catch { // Retry once - webkit sometimes needs a second click await button.click(); await expect(dialog).toBeVisible({ timeout: 10000 }); } } // ============================================================================ // EMPTY STATE - Must run FIRST before any class is created // ============================================================================ test.describe('Empty State', () => { test('shows empty state message when no classes exist', async ({ page }) => { // Clean up classes right before this specific test to avoid race conditions with parallel browsers const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`, { encoding: 'utf-8' } ); } catch { // Ignore cleanup errors } await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Wait for page to load await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible(); // Should show empty state await expect(page.locator('.empty-state')).toBeVisible(); await expect(page.getByText(/aucune classe/i)).toBeVisible(); await expect(page.getByRole('button', { name: /créer une classe/i })).toBeVisible(); }); }); // ============================================================================ // List Display // ============================================================================ test.describe('List Display', () => { test('displays all created classes in the list', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Create multiple classes const classNames = [ `Liste-6emeA-${Date.now()}`, `Liste-6emeB-${Date.now()}`, `Liste-5emeA-${Date.now()}`, ]; for (const name of classNames) { await openNewClassDialog(page); await page.locator('#class-name').fill(name); await page.getByRole('button', { name: /créer la classe/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); } // Verify ALL classes appear in the list for (const name of classNames) { await expect(page.getByText(name)).toBeVisible(); } // Verify the number of class cards matches (at least the ones we created) const classCards = page.locator('.class-card'); const count = await classCards.count(); expect(count).toBeGreaterThanOrEqual(classNames.length); }); test('displays class details correctly (level, capacity)', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Create a class with all details const className = `Details-${Date.now()}`; await openNewClassDialog(page); await page.locator('#class-name').fill(className); await page.locator('#class-level').selectOption('CM2'); await page.locator('#class-capacity').fill('25'); await page.getByRole('button', { name: /créer la classe/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); // Find the class card const classCard = page.locator('.class-card', { hasText: className }); await expect(classCard).toBeVisible(); // Verify details are displayed await expect(classCard.getByText('CM2')).toBeVisible(); await expect(classCard.getByText('25 places')).toBeVisible(); await expect(classCard.getByText('Active')).toBeVisible(); }); }); // ============================================================================ // AC1: Class Creation // ============================================================================ test.describe('AC1: Class Creation', () => { test('can create a new class with all fields', async ({ page }) => { await loginAsAdmin(page); // Navigate to classes page await page.goto(`${ALPHA_URL}/admin/classes`); await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible(); // Click "Nouvelle classe" button await openNewClassDialog(page); await expect(page.getByRole('heading', { name: /nouvelle classe/i })).toBeVisible(); // Fill form const uniqueName = `Test-E2E-${Date.now()}`; await page.locator('#class-name').fill(uniqueName); await page.locator('#class-level').selectOption('6ème'); await page.locator('#class-capacity').fill('30'); // Submit await page.getByRole('button', { name: /créer la classe/i }).click(); // Modal should close and class should appear in list await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 }); }); test('can create a class with only required fields (name)', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); await openNewClassDialog(page); // Fill only the name (required) const uniqueName = `Minimal-${Date.now()}`; await page.locator('#class-name').fill(uniqueName); // Submit button should be enabled const submitButton = page.getByRole('button', { name: /créer la classe/i }); await expect(submitButton).toBeEnabled(); await submitButton.click(); // Class should be created await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 }); }); test('submit button is disabled when name is empty', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); await openNewClassDialog(page); // Don't fill the name const submitButton = page.getByRole('button', { name: /créer la classe/i }); await expect(submitButton).toBeDisabled(); // Fill level and capacity but not name await page.locator('#class-level').selectOption('CE1'); await page.locator('#class-capacity').fill('25'); await expect(submitButton).toBeDisabled(); // Fill name - button should enable await page.locator('#class-name').fill('Test'); await expect(submitButton).toBeEnabled(); }); test('can cancel class creation', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); await openNewClassDialog(page); // Fill form await page.locator('#class-name').fill('Should-Not-Be-Created'); // Click cancel await page.getByRole('button', { name: /annuler/i }).click(); // Modal should close await expect(page.getByRole('dialog')).not.toBeVisible(); // Class should not appear in list await expect(page.getByText('Should-Not-Be-Created')).not.toBeVisible(); }); }); // ============================================================================ // AC2: Class Modification // ============================================================================ test.describe('AC2: Class Modification', () => { test('can modify an existing class', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // First create a class to modify await openNewClassDialog(page); const originalName = `ToModify-${Date.now()}`; await page.locator('#class-name').fill(originalName); await page.locator('#class-level').selectOption('CM1'); await page.getByRole('button', { name: /créer la classe/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); // Find the class card and click modify const classCard = page.locator('.class-card', { hasText: originalName }); await classCard.getByRole('button', { name: /modifier/i }).click(); // Should navigate to edit page await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/); await expect(page.getByRole('heading', { name: /modifier la classe/i })).toBeVisible(); // Modify the name const newName = `Modified-${Date.now()}`; await page.locator('#class-name').fill(newName); await page.locator('#class-level').selectOption('CM2'); await page.locator('#class-capacity').fill('28'); // Save await page.getByRole('button', { name: /enregistrer/i }).click(); // Should show success message await expect(page.getByText(/modifiée avec succès/i)).toBeVisible({ timeout: 10000 }); // Go back to list and verify await page.goto(`${ALPHA_URL}/admin/classes`); await expect(page.getByText(newName)).toBeVisible(); }); test('can cancel modification', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Create a class await openNewClassDialog(page); const originalName = `NoChange-${Date.now()}`; await page.locator('#class-name').fill(originalName); await page.getByRole('button', { name: /créer la classe/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); // Click modify const classCard = page.locator('.class-card', { hasText: originalName }); await classCard.getByRole('button', { name: /modifier/i }).click(); // Modify but cancel await page.locator('#class-name').fill('Should-Not-Change'); await page.getByRole('button', { name: /annuler/i }).click(); // Should go back to list await expect(page).toHaveURL(/\/admin\/classes$/); // Original name should still be there await expect(page.getByText(originalName)).toBeVisible(); await expect(page.getByText('Should-Not-Change')).not.toBeVisible(); }); }); // ============================================================================ // AC3: Deletion blocked if students assigned // ============================================================================ test.describe('AC3: Deletion blocked if students assigned', () => { // SKIP REASON: The Students module is not yet implemented. // HasStudentsInClassHandler currently returns 0 (stub), so all classes // appear empty and can be deleted. This test will be enabled once the // Students module allows assigning students to classes. // // When enabled, this test should: // 1. Create a class // 2. Assign at least one student to it // 3. Attempt to delete the class // 4. Verify the error message "Vous devez d'abord réaffecter les élèves" // 5. Verify the class still exists test.skip('shows warning when trying to delete class with students', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Implementation pending Students module }); }); // ============================================================================ // AC4: Empty class deletion (soft delete) // ============================================================================ test.describe('AC4: Empty class deletion (soft delete)', () => { test('can delete an empty class', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Create a class to delete await openNewClassDialog(page); const className = `ToDelete-${Date.now()}`; await page.locator('#class-name').fill(className); await page.getByRole('button', { name: /créer la classe/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText(className)).toBeVisible(); // Find and click delete button const classCard = page.locator('.class-card', { hasText: className }); await classCard.getByRole('button', { name: /supprimer/i }).click(); // Confirmation modal should appear const deleteModal = page.getByRole('alertdialog'); await expect(deleteModal).toBeVisible({ timeout: 10000 }); await expect(deleteModal.getByText(className)).toBeVisible(); // Confirm deletion await deleteModal.getByRole('button', { name: /supprimer/i }).click(); // Modal should close and class should no longer appear in list await expect(deleteModal).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText(className)).not.toBeVisible({ timeout: 10000 }); }); test('can cancel deletion', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Create a class await openNewClassDialog(page); const className = `NoDelete-${Date.now()}`; await page.locator('#class-name').fill(className); await page.getByRole('button', { name: /créer la classe/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); // Find and click delete const classCard = page.locator('.class-card', { hasText: className }); await classCard.getByRole('button', { name: /supprimer/i }).click(); // Confirmation modal should appear const deleteModal = page.getByRole('alertdialog'); await expect(deleteModal).toBeVisible({ timeout: 10000 }); // Cancel deletion await deleteModal.getByRole('button', { name: /annuler/i }).click(); // Modal should close and class should still be there await expect(deleteModal).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText(className)).toBeVisible(); }); }); // ============================================================================ // Navigation // ============================================================================ test.describe('Navigation', () => { test('can access classes page directly', async ({ page }) => { await loginAsAdmin(page); // Navigate directly to classes page await page.goto(`${ALPHA_URL}/admin/classes`); await expect(page).toHaveURL(/\/admin\/classes/); await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible(); }); test('breadcrumb navigation works on edit page', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Create a class await openNewClassDialog(page); const className = `Breadcrumb-${Date.now()}`; await page.locator('#class-name').fill(className); await page.getByRole('button', { name: /créer la classe/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); // Go to edit page const classCard = page.locator('.class-card', { hasText: className }); await classCard.getByRole('button', { name: /modifier/i }).click(); // Click breadcrumb to go back await page.getByRole('link', { name: 'Classes' }).click(); await expect(page).toHaveURL(/\/admin\/classes$/); }); }); // ============================================================================ // Validation // ============================================================================ test.describe('Validation', () => { test('shows validation for class name length', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); await openNewClassDialog(page); // Try a name that's too short (1 char) await page.locator('#class-name').fill('A'); // The HTML5 minlength validation should prevent submission // or show an error const nameInput = page.locator('#class-name'); const isInvalid = await nameInput.evaluate( (el: HTMLInputElement) => !el.validity.valid ); expect(isInvalid).toBe(true); }); }); });