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 PARENT_EMAIL = 'e2e-parent-schedule@example.com'; const PARENT_PASSWORD = 'ParentSchedule123'; const STUDENT_EMAIL = 'e2e-parent-sched-student@example.com'; const STUDENT_PASSWORD = 'StudentParentSched123'; const STUDENT2_EMAIL = 'e2e-parent-sched-student2@example.com'; const STUDENT2_PASSWORD = 'StudentParentSched2_123'; const TEACHER_EMAIL = 'e2e-parent-sched-teacher@example.com'; const TEACHER_PASSWORD = 'TeacherParentSched123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); function runSql(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 } } 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: schoolId!, academicYearId: academicYearId! }; } function currentWeekdayIso(): number { const jsDay = new Date().getDay(); if (jsDay === 0) return 5; // Sunday → seed for Friday if (jsDay === 6) return 5; // Saturday → seed for Friday return jsDay; } function daysBackToSeededWeekday(): number { const jsDay = new Date().getDay(); if (jsDay === 6) return 1; // Saturday → go back 1 day to Friday if (jsDay === 0) return 2; // Sunday → go back 2 days to Friday return 0; } function seededDayName(): string { const jsDay = new Date().getDay(); const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay; return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!; } async function navigateToSeededDay(page: import('@playwright/test').Page) { const back = daysBackToSeededWeekday(); if (back === 0) return; const targetDay = seededDayName(); const targetPattern = new RegExp(targetDay, 'i'); const prevBtn = page.getByLabel('Précédent'); await expect(prevBtn).toBeVisible({ timeout: 10000 }); const deadline = Date.now() + 15000; let navigated = false; while (Date.now() < deadline && !navigated) { for (let i = 0; i < back; i++) { await prevBtn.click(); } await page.waitForTimeout(500); const title = await page.locator('.day-title').textContent(); if (title && targetPattern.test(title)) { navigated = true; } } await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({ timeout: 5000 }); } async function loginAsParent(page: import('@playwright/test').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 page.waitForURL(/\/dashboard/, { timeout: 60000 }); } /** * Multi-child parents land on the summary view (no child auto-selected). * This helper selects the first actual child (skipping the "Tous" button). */ async function selectFirstChild(page: import('@playwright/test').Page) { const childButtons = page.locator('.child-button'); await expect(childButtons.first()).toBeVisible({ timeout: 15000 }); const count = await childButtons.count(); if (count > 2) { // Multi-child: "Tous" is at index 0, first child at index 1 await childButtons.nth(1).click(); } // Single child is auto-selected, nothing to do } test.describe('Parent Schedule Consultation (Story 4.4)', () => { test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { // Clear caches to prevent stale data from previous runs try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache student_guardians.cache --env=dev 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pools may not exist } // Create users (idempotent - returns existing if already created) 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' } ); 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' } ); 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' } ); 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' } ); const { schoolId, academicYearId } = resolveDeterministicIds(); // Create classes try { runSql( `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-ParentSched-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runSql( `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-ParentSched-5B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } // Create subjects try { runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-ParentSched-Maths', 'E2EPARMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-ParentSched-SVT', 'E2EPARSVT', '#22c55e', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } // Clean up schedule data try { runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name LIKE 'E2E-ParentSched-%' AND tenant_id = '${TENANT_ID}')`); } catch { // Table may not exist } // Clean up calendar entries try { runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`); } catch { // Table may not exist } // Assign students to classes runSql( `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + `FROM users u, school_classes c ` + `WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); runSql( `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` + `FROM users u, school_classes c ` + `WHERE u.email = '${STUDENT2_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); // Clean up any existing guardian links for our parent try { runSql( `DELETE FROM student_guardians WHERE guardian_id IN (SELECT id FROM users WHERE email = '${PARENT_EMAIL}' AND tenant_id = '${TENANT_ID}') AND tenant_id = '${TENANT_ID}'` ); } catch { // Table may not exist } // Create parent-student links runSql( `INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` + `SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` + `FROM users s, users p ` + `WHERE s.email = '${STUDENT_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` + `AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING` ); runSql( `INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` + `SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` + `FROM users s, users p ` + `WHERE s.email = '${STUDENT2_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` + `AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING` ); // Create schedule slots const dayOfWeek = currentWeekdayIso(); runSql( `INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '08:00', '09:00', 'Salle A1', true, NOW(), NOW() ` + `FROM school_classes c, ` + `(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + `WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'` ); runSql( `INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '10:00', '11:00', 'Labo SVT', true, NOW(), NOW() ` + `FROM school_classes c, ` + `(SELECT id FROM subjects WHERE code = 'E2EPARSVT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + `WHERE c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}'` ); // Late-night slot for child 1 (6A) — always "next" during normal test hours runSql( `INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '23:00', '23:30', 'Salle A1', true, NOW(), NOW() ` + `FROM school_classes c, ` + `(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` + `(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` + `WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'` ); clearCache(); }); // ====================================================================== // AC1: Single child view // ====================================================================== test.describe('AC1: Single child day view', () => { test('parent can navigate to parent-schedule page', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); }); test('child selector shows children', async ({ page }) => { await loginAsParent(page); // Intercept the API call to debug const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/me/children') && !resp.url().includes('schedule'), { timeout: 30000 } ); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); const response = await responsePromise; expect(response.status()).toBe(200); // Wait for child selector to finish loading const childSelector = page.locator('.child-selector'); await expect(childSelector).toBeVisible({ timeout: 15000 }); }); test('day view shows schedule for selected child', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); await selectFirstChild(page); await navigateToSeededDay(page); // Wait for slots to load const slots = page.locator('[data-testid="schedule-slot"]'); await expect(slots.first()).toBeVisible({ timeout: 20000 }); }); }); // ====================================================================== // AC2: Multi-child view // ====================================================================== test.describe('AC2: Multi-child selection', () => { test('can switch between children', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); // Multi-child: "Tous" + N children buttons const childButtons = page.locator('.child-button'); const count = await childButtons.count(); if (count > 2) { // Select first child (index 1, after "Tous") await childButtons.nth(1).click(); await navigateToSeededDay(page); await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 20000 }); // Switch to second child (index 2) await childButtons.nth(2).click(); await page.waitForTimeout(500); await navigateToSeededDay(page); await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 20000 }); // Switch back to "Tous" summary view await childButtons.nth(0).click(); await expect(page.locator('.multi-child-summary')).toBeVisible({ timeout: 10000 }); } }); }); // ====================================================================== // AC3: Navigation (day/week views) // ====================================================================== test.describe('AC3: Navigation', () => { test('day view is the default view', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); await selectFirstChild(page); const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' }); await expect(dayButton).toHaveClass(/active/, { timeout: 5000 }); }); test('can switch to week view', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); await selectFirstChild(page); await navigateToSeededDay(page); // Wait for slots to load await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 20000 }); // Switch to week view (retry click if view doesn't switch — Svelte hydration race) const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' }); await expect(weekButton).toBeVisible({ timeout: 10000 }); await weekButton.click(); const lunHeader = page.getByText('Lun', { exact: true }); try { await expect(lunHeader).toBeVisible({ timeout: 10000 }); } catch { await weekButton.click(); await expect(lunHeader).toBeVisible({ timeout: 30000 }); } await expect(page.getByText('Ven', { exact: true })).toBeVisible(); }); test('can navigate between days', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); await selectFirstChild(page); await navigateToSeededDay(page); await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 20000 }); // Navigate forward and wait for the new day to load await page.getByLabel('Suivant').click(); // Wait for the day title to change, confirming navigation completed await page.waitForTimeout(3000); // Navigate back to the original day await page.getByLabel('Précédent').click(); // Wait for data to reload after navigation await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 30000 }); }); }); // ====================================================================== // AC1: Next class highlighting (P0) // ====================================================================== test.describe('AC1: Next class highlighting', () => { test('next class is highlighted with badge on today view', async ({ page }) => { // Next class highlighting only works when viewing today's date const jsDay = new Date().getDay(); test.skip(jsDay === 0 || jsDay === 6, 'Next class highlighting only works on weekdays'); // The seeded slot is at 23:00 — if the test runs after 22:30 the slot // may be current/past and won't have the "next" class. const hour = new Date().getHours(); test.skip(hour >= 23, 'Seeded 23:00 slot is past/current — cannot test next-class highlighting'); await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); await selectFirstChild(page); // Wait for schedule slots to load await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 20000 }); // The 23:00 slot should always be "next" during normal test hours const nextSlot = page.locator('.slot-item.next'); await expect(nextSlot).toBeVisible({ timeout: 10000 }); // Verify the "Prochain" badge is displayed await expect(nextSlot.locator('.next-badge')).toBeVisible(); await expect(nextSlot.locator('.next-badge')).toHaveText('Prochain'); }); }); // ====================================================================== // AC2: Multi-child content verification (P1) // ====================================================================== test.describe('AC2: Multi-child schedule content', () => { test('switching children shows different subjects', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); const childButtons = page.locator('.child-button'); const count = await childButtons.count(); // "Tous" + at least 2 children = 3 buttons minimum test.skip(count < 3, 'Need at least 2 children for this test'); // Select first child (index 1, after "Tous") await childButtons.nth(1).click(); await navigateToSeededDay(page); // First child (6A) should show Maths await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 20000 }); await expect(page.getByText('E2E-ParentSched-Maths').first()).toBeVisible({ timeout: 5000 }); // Switch to second child (index 2) (5B) — should show SVT await childButtons.nth(2).click(); await navigateToSeededDay(page); await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({ timeout: 20000 }); await expect(page.getByText('E2E-ParentSched-SVT').first()).toBeVisible({ timeout: 5000 }); }); }); // ====================================================================== // AC5: Offline mode // ====================================================================== test.describe('AC5: Offline mode', () => { test('shows offline banner when network is lost', async ({ page, context }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`); await selectFirstChild(page); await navigateToSeededDay(page); // Wait for schedule to load await expect( page.locator('[data-testid="schedule-slot"]').first() ).toBeVisible({ timeout: 20000 }); // Go offline await context.setOffline(true); const offlineBanner = page.locator('.offline-banner[role="status"]'); await expect(offlineBanner).toBeVisible({ timeout: 5000 }); await expect(offlineBanner.getByText('Hors ligne')).toBeVisible(); // Restore online await context.setOffline(false); await expect(offlineBanner).not.toBeVisible({ timeout: 5000 }); }); }); // ====================================================================== // Navigation link // ====================================================================== test.describe('Navigation link', () => { test('EDT enfants link is visible for parent role', async ({ page }) => { await loginAsParent(page); const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' }); await expect(navLink).toBeVisible({ timeout: 10000 }); }); test('clicking EDT enfants link navigates to parent-schedule page', async ({ page }) => { await loginAsParent(page); const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' }); await expect(navLink).toBeVisible({ timeout: 10000 }); await navLink.click(); await expect(page).toHaveURL(/\/dashboard\/parent-schedule/); await expect( page.getByRole('heading', { name: /emploi du temps des enfants/i }) ).toBeVisible({ timeout: 15000 }); }); }); });