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-calendar-admin@example.com'; const ADMIN_PASSWORD = 'CalendarTest123'; const TEACHER_EMAIL = 'e2e-calendar-teacher@example.com'; const TEACHER_PASSWORD = 'CalendarTeacher123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; // Dynamic future weekday for pedagogical day (avoids stale hardcoded dates, French holidays, and summer vacation) const PED_DAY_DATE = (() => { const d = new Date(); d.setDate(d.getDate() + 30); // ~1 month ahead, stays within school time (avoids summer vacation) while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1); // Skip known French fixed holidays (MM-DD) const holidays = ['01-01', '05-01', '05-08', '07-14', '08-15', '11-01', '11-11', '12-25']; let mmdd = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; if (holidays.includes(mmdd)) d.setDate(d.getDate() + 1); while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1); // Double-check after weekend skip mmdd = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; if (holidays.includes(mmdd)) d.setDate(d.getDate() + 1); while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })(); const PED_DAY_LABEL = 'Formation enseignants'; // Serial: empty state must be verified before import, display after import test.describe.configure({ mode: 'serial' }); test.describe('Calendar Management (Story 2.11)', () => { test.beforeAll(async () => { const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); // Create admin and teacher test users 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=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`, { encoding: 'utf-8' } ); // Clean calendar entries to ensure empty state try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'" 2>&1`, { encoding: 'utf-8' } ); } catch { // Table might not have data yet } 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 } }); 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: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } async function loginAsTeacher(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(TEACHER_EMAIL); await page.locator('#password').fill(TEACHER_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 60000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } // ============================================================================ // Navigation (AC1) // ============================================================================ test.describe('Navigation', () => { test('[P1] can access calendar page from admin navigation', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/classes`); // Hover "Année scolaire" category to reveal dropdown const nav = page.locator('.desktop-nav'); await nav.getByRole('button', { name: /année scolaire/i }).hover(); await nav.getByRole('menuitem', { name: /calendrier/i }).click(); await expect(page).toHaveURL(/\/admin\/calendar/); await expect( page.getByRole('heading', { name: /calendrier scolaire/i }) ).toBeVisible(); }); test('[P1] can access calendar page directly', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await expect(page).toHaveURL(/\/admin\/calendar/); await expect( page.getByRole('heading', { name: /calendrier scolaire/i }) ).toBeVisible(); }); }); // ============================================================================ // Authorization (AC1) // ============================================================================ test.describe('Authorization', () => { test('[P0] teacher can access admin layout but calendar returns error', async ({ page }) => { await loginAsTeacher(page); await page.goto(`${ALPHA_URL}/admin/calendar`); // Teacher can access admin layout (ROLE_PROF in ADMIN_ROLES for image-rights) // but calendar page may show access denied from backend await expect(page).toHaveURL(/\/admin\/calendar/, { timeout: 10000 }); }); }); // ============================================================================ // Empty State (AC1) // ============================================================================ test.describe('Empty State', () => { test('[P1] shows empty state when no calendar configured', async ({ page }) => { // Clean up right before test to avoid race conditions 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_calendar_entries WHERE tenant_id = '${TENANT_ID}'" 2>&1`, { encoding: 'utf-8' } ); } catch { // Ignore cleanup errors } 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 } await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await expect( page.getByRole('heading', { name: /calendrier scolaire/i }) ).toBeVisible(); await expect(page.getByText(/aucun calendrier configuré/i)).toBeVisible({ timeout: 10000 }); await expect( page.getByRole('button', { name: /importer le calendrier officiel/i }) ).toBeVisible(); }); test('[P1] displays three year selector tabs', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); const tabs = page.locator('.year-tab'); await expect(tabs).toHaveCount(3); }); }); // ============================================================================ // Import Official Calendar (AC2) // ============================================================================ test.describe('Import Official Calendar', () => { test('[P1] import button opens modal with zone selector', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog.getByText(/importer le calendrier officiel/i)).toBeVisible(); }); test('[P2] import modal shows zones A, B, C', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await expect(dialog.getByText('Zone A')).toBeVisible(); await expect(dialog.getByText('Zone B')).toBeVisible(); await expect(dialog.getByText('Zone C')).toBeVisible(); }); test('[P2] can cancel import modal', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await dialog.getByRole('button', { name: /annuler/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 5000 }); }); test('[P2] modal closes on Escape key', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Press Escape to dismiss the modal await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible({ timeout: 5000 }); }); test('[P0] shows error when import API fails', async ({ page }) => { await loginAsAdmin(page); // Intercept import POST to simulate server error await page.route('**/calendar/import-official', (route) => route.fulfill({ status: 500, contentType: 'application/ld+json', body: JSON.stringify({ 'hydra:description': 'Erreur serveur interne' }) }) ); await page.goto(`${ALPHA_URL}/admin/calendar`); await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Submit import (intercepted → 500) await dialog.getByRole('button', { name: /^importer$/i }).click(); // Close modal to reveal error message on the page await dialog.getByRole('button', { name: /annuler/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 5000 }); // Error alert should be visible await expect(page.locator('.alert-error')).toBeVisible({ timeout: 5000 }); }); test('[P0] importing zone A populates calendar with entries', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); // Wait for empty state to be displayed await expect(page.getByText(/aucun calendrier configuré/i)).toBeVisible({ timeout: 10000 }); // Open import modal from empty state CTA await page.getByRole('button', { name: /importer le calendrier officiel/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Zone A is pre-selected, click import await dialog.getByRole('button', { name: /^importer$/i }).click(); // Modal should close await expect(dialog).not.toBeVisible({ timeout: 10000 }); // Success message await expect(page.getByText(/calendrier officiel importé/i)).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // Calendar Display after Import (AC1, AC3, AC4) // ============================================================================ test.describe('Calendar Display', () => { test('[P1] shows zone badge after import', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await expect(page.locator('.zone-badge')).toContainText('Zone A', { timeout: 10000 }); }); test('[P0] shows holidays section with actual entries', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); // Verify the section heading exists with a count > 0 await expect( page.getByRole('heading', { name: /jours fériés/i }) ).toBeVisible({ timeout: 10000 }); // Verify specific imported holiday entries are displayed await expect(page.getByText('Toussaint', { exact: true })).toBeVisible(); await expect(page.getByText('Noël', { exact: true })).toBeVisible(); // Verify entry cards exist (not just the heading) const holidaySection = page.locator('.entry-section').filter({ has: page.getByRole('heading', { name: /jours fériés/i }) }); await expect(holidaySection.locator('.entry-card').first()).toBeVisible(); }); test('[P1] shows vacations section with specific entries', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await expect( page.getByRole('heading', { name: /vacances scolaires/i }) ).toBeVisible({ timeout: 10000 }); // Verify specific vacation entry names from Zone A official data await expect(page.getByText('Hiver')).toBeVisible(); await expect(page.getByText('Printemps')).toBeVisible(); // Verify entry cards exist within the vacation section const vacationSection = page.locator('.entry-section').filter({ has: page.getByRole('heading', { name: /vacances scolaires/i }) }); await expect(vacationSection.locator('.entry-card').first()).toBeVisible(); const cardCount = await vacationSection.locator('.entry-card').count(); expect(cardCount).toBeGreaterThanOrEqual(4); }); test('[P2] shows legend with color indicators', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); // Wait for calendar to load await expect( page.getByRole('heading', { name: /jours fériés/i }) ).toBeVisible({ timeout: 10000 }); await expect( page.locator('.legend-item').filter({ hasText: /jours fériés/i }) ).toBeVisible(); await expect( page.locator('.legend-item').filter({ hasText: /vacances/i }) ).toBeVisible(); await expect( page.locator('.legend-item').filter({ hasText: /journées pédagogiques/i }) ).toBeVisible(); }); test('[P2] can switch between year tabs', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); // Wait for calendar to load await expect( page.getByRole('heading', { name: /jours fériés/i }) ).toBeVisible({ timeout: 10000 }); const tabs = page.locator('.year-tab'); // Middle tab (current year) should be active by default await expect(tabs.nth(1)).toHaveClass(/year-tab-active/); // Click next year tab await tabs.nth(2).click(); await expect(tabs.nth(2)).toHaveClass(/year-tab-active/, { timeout: 5000 }); // Click previous year tab await tabs.nth(0).click(); await expect(tabs.nth(0)).toHaveClass(/year-tab-active/, { timeout: 5000 }); }); }); // ============================================================================ // Pedagogical Day (AC5) // ============================================================================ test.describe('Pedagogical Day', () => { // Clean up any existing ped day with the same date before the serial ped day tests test.beforeAll(async () => { 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_calendar_entries WHERE tenant_id = '${TENANT_ID}' AND type = 'pedagogical_day' AND start_date = '${PED_DAY_DATE}'" 2>&1`, { encoding: 'utf-8' } ); } catch { // Ignore cleanup errors } 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 } }); test('[P1] add pedagogical day button is visible', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await expect( page.getByRole('button', { name: /ajouter journée pédagogique/i }) ).toBeVisible(); }); test('[P1] pedagogical day modal opens with form fields', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Form fields await expect(dialog.locator('#ped-date')).toBeVisible(); await expect(dialog.locator('#ped-label')).toBeVisible(); await expect(dialog.locator('#ped-description')).toBeVisible(); }); test('[P2] can cancel pedagogical day modal', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); await dialog.getByRole('button', { name: /annuler/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 5000 }); }); test('[P1] submit button disabled when fields empty', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); const submitButton = dialog.getByRole('button', { name: /^ajouter$/i }); // Both fields empty → button disabled await expect(submitButton).toBeDisabled(); // Fill only date → still disabled (label missing) await dialog.locator('#ped-date').fill(PED_DAY_DATE); await expect(submitButton).toBeDisabled(); // Clear date, fill only label → still disabled (date missing) await dialog.locator('#ped-date').fill(''); await dialog.locator('#ped-label').fill(PED_DAY_LABEL); await expect(submitButton).toBeDisabled(); // Fill both date and label → button enabled await dialog.locator('#ped-date').fill(PED_DAY_DATE); await expect(submitButton).toBeEnabled(); }); test('[P0] can add a pedagogical day successfully', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); // Wait for calendar to load await expect( page.getByRole('heading', { name: /jours fériés/i }) ).toBeVisible({ timeout: 10000 }); // Open modal await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Fill form with dynamic future date await dialog.locator('#ped-date').fill(PED_DAY_DATE); await dialog.locator('#ped-label').fill(PED_DAY_LABEL); await dialog.locator('#ped-description').fill('Journée de formation continue'); // Submit and wait for API response const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/calendar/pedagogical-day') && resp.request().method() === 'POST' ); await dialog.getByRole('button', { name: /^ajouter$/i }).click(); const response = await responsePromise; expect(response.status()).toBeLessThan(400); // Modal should close await expect(dialog).not.toBeVisible({ timeout: 15000 }); // Success message await expect( page.getByText(/journée pédagogique ajoutée/i) ).toBeVisible({ timeout: 10000 }); }); test('[P1] added pedagogical day appears in list', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); // Wait for calendar to load await expect( page.getByRole('heading', { name: /jours fériés/i }) ).toBeVisible({ timeout: 10000 }); // Pedagogical day section should exist with the added day await expect( page.getByRole('heading', { name: /journées pédagogiques/i }) ).toBeVisible(); await expect(page.getByText(PED_DAY_LABEL)).toBeVisible(); }); test('[P2] pedagogical day shows distinct amber styling', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/calendar`); // Wait for calendar to load await expect( page.getByRole('heading', { name: /journées pédagogiques/i }) ).toBeVisible({ timeout: 10000 }); // Verify the pedagogical day section has an amber indicator dot const pedSection = page.locator('.entry-section').filter({ has: page.getByRole('heading', { name: /journées pédagogiques/i }) }); const sectionDot = pedSection.locator('.section-dot'); await expect(sectionDot).toBeVisible(); await expect(sectionDot).toHaveCSS('background-color', 'rgb(245, 158, 11)'); }); }); });