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-replacements-admin@example.com'; const ADMIN_PASSWORD = 'ReplacementsTest123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); function runCommand(sql: string) { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`, { encoding: 'utf-8' } ); } function clearCache() { try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pool may not exist in all environments } } function resolveDeterministicIds(): { schoolId: string; academicYearId: string } { const output = execSync( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` + `$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` + `echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` + `' 2>&1`, { encoding: 'utf-8' } ).trim(); const [schoolId, academicYearId] = output.split('\n'); return { schoolId, academicYearId }; } async function loginAsAdmin(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.waitForLoadState('networkidle'); await page.locator('#email').fill(ADMIN_EMAIL); await page.locator('#password').fill(ADMIN_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } async function waitForPageReady(page: import('@playwright/test').Page) { await expect( page.getByRole('heading', { name: /remplacements enseignants/i }) ).toBeVisible({ timeout: 15000 }); await expect( page.locator('.empty-state, .replacements-table, .alert-error') ).toBeVisible({ timeout: 15000 }); } async function openCreateDialog(page: import('@playwright/test').Page) { const button = page.getByRole('button', { name: /nouveau remplacement/i }).first(); await expect(button).toBeEnabled(); await button.click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Wait for dropdown options to load (more than just the placeholder) await expect(page.locator('#replaced-teacher option')).not.toHaveCount(1, { timeout: 10000 }); } function getTodayDate(): string { return new Date().toISOString().split('T')[0]; } function getFutureDate(days: number): string { const date = new Date(); date.setDate(date.getDate() + days); return date.toISOString().split('T')[0]; } test.describe('Teacher Replacements (Story 2.9)', () => { test.beforeAll(async () => { 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' } ); execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-replaced-teacher@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`, { encoding: 'utf-8' } ); execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-replacement-teacher@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`, { encoding: 'utf-8' } ); const { schoolId, academicYearId } = resolveDeterministicIds(); runCommand( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Repl-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runCommand( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Repl-Français', 'E2EFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); clearCache(); }); test.beforeEach(async () => { try { runCommand(`DELETE FROM replacement_classes WHERE replacement_id IN (SELECT id FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}')`); runCommand(`DELETE FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}'`); } catch { // Tables may not exist yet if migration hasn't run } // Re-ensure class and subject exist (may have been deleted by parallel specs) const { schoolId, academicYearId } = resolveDeterministicIds(); try { runCommand( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Repl-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runCommand( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Repl-Français', 'E2EFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // Ignore if already exists } clearCache(); }); // ============================================================================ // Navigation // ============================================================================ test.describe('Navigation', () => { test('replacements link appears in admin navigation', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin`); // Hover "Organisation" category to reveal dropdown const nav = page.locator('.desktop-nav'); await nav.getByRole('button', { name: /organisation/i }).hover(); const navLink = nav.getByRole('menuitem', { name: /remplacements/i }); await expect(navLink).toBeVisible({ timeout: 15000 }); }); test('can navigate to replacements page', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/replacements`); await expect(page.getByRole('heading', { name: /remplacements enseignants/i })).toBeVisible({ timeout: 15000 }); }); }); // ============================================================================ // Empty State // ============================================================================ test.describe('Empty State', () => { test('shows empty state when no replacements exist', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/replacements`); await waitForPageReady(page); await expect(page.getByText(/aucun remplacement actif/i)).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC1: Create Replacement // ============================================================================ test.describe('AC1: Create Replacement', () => { test('can create a new replacement', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/replacements`); await waitForPageReady(page); await openCreateDialog(page); // Select replaced teacher const replacedSelect = page.locator('#replaced-teacher'); await expect(replacedSelect).toBeVisible(); await replacedSelect.selectOption({ index: 1 }); // Select replacement teacher (different from replaced) // Index 0 = placeholder, index 1 = same teacher (disabled), index 2 = next available const replacementSelect = page.locator('#replacement-teacher'); await expect(replacementSelect).toBeVisible(); await replacementSelect.selectOption({ index: 2 }); // Set dates await page.locator('#start-date').fill(getTodayDate()); await page.locator('#end-date').fill(getFutureDate(30)); // Select class and subject const firstClassSelect = page.locator('.class-pair-row select').first(); await firstClassSelect.selectOption({ index: 1 }); const firstSubjectSelect = page.locator('.class-pair-row select').nth(1); await firstSubjectSelect.selectOption({ index: 1 }); // Submit await page.getByRole('button', { name: /désigner le remplaçant/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText(/remplacement créé/i)).toBeVisible({ timeout: 10000 }); // Verify table shows the replacement const table = page.locator('.replacements-table'); await expect(table).toBeVisible({ timeout: 10000 }); const rows = table.locator('tbody tr'); const rowCount = await rows.count(); expect(rowCount).toBeGreaterThanOrEqual(1); }); test('cancel closes the modal without creating', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/replacements`); await waitForPageReady(page); await openCreateDialog(page); await page.getByRole('button', { name: /annuler/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 }); }); }); // ============================================================================ // AC3: End Replacement // ============================================================================ test.describe('AC3: End Replacement', () => { test('can end an active replacement', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/replacements`); await waitForPageReady(page); // First create a replacement await openCreateDialog(page); await page.locator('#replaced-teacher').selectOption({ index: 1 }); await page.locator('#replacement-teacher').selectOption({ index: 2 }); await page.locator('#start-date').fill(getTodayDate()); await page.locator('#end-date').fill(getFutureDate(30)); const firstClassSelect = page.locator('.class-pair-row select').first(); await firstClassSelect.selectOption({ index: 1 }); const firstSubjectSelect = page.locator('.class-pair-row select').nth(1); await firstSubjectSelect.selectOption({ index: 1 }); await page.getByRole('button', { name: /désigner le remplaçant/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); // Now end it const endButton = page.locator('.btn-remove').first(); await endButton.click(); const confirmDialog = page.getByRole('alertdialog'); await expect(confirmDialog).toBeVisible({ timeout: 5000 }); await expect(page.getByText(/perdra immédiatement l'accès/i)).toBeVisible(); await confirmDialog.getByRole('button', { name: /terminer/i }).click(); await expect(confirmDialog).not.toBeVisible({ timeout: 15000 }); await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 15000 }); }); }); // ============================================================================ // AC4: Active Replacements Display // ============================================================================ test.describe('AC4: Active Replacements Display', () => { test('shows countdown for active replacements', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/replacements`); await waitForPageReady(page); // Create a replacement await openCreateDialog(page); await page.locator('#replaced-teacher').selectOption({ index: 1 }); await page.locator('#replacement-teacher').selectOption({ index: 2 }); await page.locator('#start-date').fill(getTodayDate()); await page.locator('#end-date').fill(getFutureDate(10)); const firstClassSelect = page.locator('.class-pair-row select').first(); await firstClassSelect.selectOption({ index: 1 }); const firstSubjectSelect = page.locator('.class-pair-row select').nth(1); await firstSubjectSelect.selectOption({ index: 1 }); await page.getByRole('button', { name: /désigner le remplaçant/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); // Verify countdown is displayed await expect(page.locator('.countdown')).toBeVisible({ timeout: 10000 }); await expect(page.getByText(/jours? restants?/i)).toBeVisible(); // Verify status badge await expect(page.locator('.status-active')).toBeVisible(); }); }); });