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-schedule-admin@example.com'; const ADMIN_PASSWORD = 'ScheduleTest123'; const TEACHER_EMAIL = 'e2e-schedule-teacher@example.com'; const TEACHER_PASSWORD = 'ScheduleTeacher123'; 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 cleanupScheduleData() { try { runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`); } catch { // Table may not exist yet } } function seedTeacherAssignments() { const { academicYearId } = resolveDeterministicIds(); try { // Assign test teacher to ALL classes × ALL subjects so any dropdown combo is valid runSql( `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.tenant_id = '${TENANT_ID}' ` + `AND s.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); } catch { // Table may not exist } } 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() ]); } async function waitForScheduleReady(page: import('@playwright/test').Page) { await expect(page.getByRole('heading', { name: /emploi du temps/i })).toBeVisible({ timeout: 15000 }); // Wait for either the grid or the empty state to appear await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({ timeout: 15000 }); } async function fillSlotForm( dialog: import('@playwright/test').Locator, options: { className?: string; dayValue?: string; startTime?: string; endTime?: string; room?: string; } = {} ) { const { className, dayValue = '1', startTime = '09:00', endTime = '10:00', room } = options; if (className) { await dialog.locator('#slot-class').selectOption({ label: className }); } // Wait for assignments to load — only the test teacher is assigned, // so the teacher dropdown filters down to 1 option const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])'); await expect(teacherOptions).toHaveCount(1, { timeout: 10000 }); await dialog.locator('#slot-subject').selectOption({ index: 1 }); await dialog.locator('#slot-teacher').selectOption({ index: 1 }); await dialog.locator('#slot-day').selectOption(dayValue); await dialog.locator('#slot-start').fill(startTime); await dialog.locator('#slot-end').fill(endTime); if (room) { await dialog.locator('#slot-room').fill(room); } } test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)', () => { // Tests share database state (same tenant, users, assignments) so they must run sequentially test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { // 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 teacher user 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(); // Ensure test class exists 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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } // Ensure second test class exists (for conflict tests across 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-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } // Ensure test subjects exist 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-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } 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-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } cleanupScheduleData(); clearCache(); }); test.beforeEach(async () => { cleanupScheduleData(); try { runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`); } catch { // Table may not exist } seedTeacherAssignments(); clearCache(); }); // ========================================================================== // Navigation // ========================================================================== test.describe('Navigation', () => { test('schedule link appears in admin navigation under Organisation', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin`); const nav = page.locator('.desktop-nav'); await nav.getByRole('button', { name: /organisation/i }).hover(); const navLink = nav.getByRole('menuitem', { name: /emploi du temps/i }); await expect(navLink).toBeVisible({ timeout: 15000 }); }); test('can navigate to schedule page', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await expect( page.getByRole('heading', { name: /emploi du temps/i }) ).toBeVisible({ timeout: 15000 }); }); }); // ========================================================================== // AC1: Schedule Grid // ========================================================================== test.describe('AC1: Schedule Grid', () => { test('displays weekly grid with day columns', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); // Check day headers are present await expect(page.getByText('Lundi')).toBeVisible(); await expect(page.getByText('Mardi')).toBeVisible(); await expect(page.getByText('Mercredi')).toBeVisible(); await expect(page.getByText('Jeudi')).toBeVisible(); await expect(page.getByText('Vendredi')).toBeVisible(); }); test('has class filter dropdown', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); const classFilter = page.locator('#filter-class'); await expect(classFilter).toBeVisible(); // Should have at least the placeholder option + one class const options = classFilter.locator('option'); await expect(options).not.toHaveCount(1, { timeout: 10000 }); }); test('has teacher filter dropdown', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); const teacherFilter = page.locator('#filter-teacher'); await expect(teacherFilter).toBeVisible(); }); }); // ========================================================================== // AC2: Slot Creation // ========================================================================== test.describe('AC2: Slot Creation', () => { test('clicking on a time cell opens creation modal', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); // Click on a time cell in the grid const timeCell = page.locator('.time-cell').first(); await timeCell.click(); // Modal should appear const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); await expect( dialog.getByRole('heading', { name: /nouveau créneau/i }) ).toBeVisible(); }); test('creation form has required fields', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); // Open creation modal const timeCell = page.locator('.time-cell').first(); await timeCell.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Check required form fields await expect(dialog.locator('#slot-subject')).toBeVisible(); await expect(dialog.locator('#slot-teacher')).toBeVisible(); await expect(dialog.locator('#slot-day')).toBeVisible(); await expect(dialog.locator('#slot-start')).toBeVisible(); await expect(dialog.locator('#slot-end')).toBeVisible(); await expect(dialog.locator('#slot-room')).toBeVisible(); // Submit button should be disabled when fields are empty const submitButton = dialog.getByRole('button', { name: /créer/i }); await expect(submitButton).toBeDisabled(); }); test('can close creation modal with cancel button', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); const timeCell = page.locator('.time-cell').first(); await timeCell.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Click cancel await dialog.getByRole('button', { name: /annuler/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 5000 }); }); test('can close creation modal with Escape key', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); const timeCell = page.locator('.time-cell').first(); await timeCell.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Press Escape await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible({ timeout: 5000 }); }); test('can create a slot and see it in the grid', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); // Open creation modal const timeCell = page.locator('.time-cell').first(); await timeCell.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); await fillSlotForm(dialog, { room: 'A101' }); // Submit const submitButton = dialog.getByRole('button', { name: /créer/i }); await expect(submitButton).toBeEnabled(); await submitButton.click(); // Modal should close await expect(dialog).not.toBeVisible({ timeout: 10000 }); // Slot card should appear in the grid await expect(page.locator('.slot-card')).toBeVisible({ timeout: 10000 }); // Should show room on the slot card await expect(page.locator('.slot-card').getByText('A101')).toBeVisible(); }); test('filters subjects and teachers by class assignment', async ({ page }) => { const { academicYearId } = resolveDeterministicIds(); // Clear all assignments, seed exactly one: teacher → class 6A → first subject runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`); runSql( `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + `FROM users u, school_classes c, (SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' ORDER BY name LIMIT 1) s ` + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); clearCache(); await loginAsAdmin(page); await page.goto(`${ALPHA_URL}/admin/schedule`); await waitForScheduleReady(page); // Open creation modal const timeCell = page.locator('.time-cell').first(); await timeCell.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Select class E2E-Schedule-6A (triggers loadAssignments for this class) await dialog.locator('#slot-class').selectOption({ label: 'E2E-Schedule-6A' }); // Subject dropdown should be filtered to only the assigned subject // (auto-retry handles the async assignment loading) const subjectOptions = dialog.locator('#slot-subject option:not([value=""])'); await expect(subjectOptions).toHaveCount(1, { timeout: 15000 }); // Teacher dropdown should only show the assigned teacher const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])'); await expect(teacherOptions).toHaveCount(1, { timeout: 10000 }); }); }); });