import { test, expect, type Page } 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-parenthw-admin@example.com'; const ADMIN_PASSWORD = 'AdminParentHW123'; const PARENT_EMAIL = 'e2e-parenthw-parent@example.com'; const PARENT_PASSWORD = 'ParentHomework123'; const TEACHER_EMAIL = 'e2e-parenthw-teacher@example.com'; const TEACHER_PASSWORD = 'TeacherParentHW123'; const STUDENT1_EMAIL = 'e2e-parenthw-student1@example.com'; const STUDENT1_PASSWORD = 'Student1ParentHW123'; const STUDENT2_EMAIL = 'e2e-parenthw-student2@example.com'; const STUDENT2_PASSWORD = 'Student2ParentHW123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; const projectRoot = join(__dirname, '../..'); const composeFile = join(projectRoot, 'compose.yaml'); let student1UserId: string; let student2UserId: string; 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 extractUserId(output: string): string { const match = output.match(/User ID\s+([a-f0-9-]{36})/i); if (!match) { throw new Error(`Could not extract User ID from command output:\n${output}`); } return match[1]; } function getNextWeekday(daysFromNow: number): string { const date = new Date(); date.setDate(date.getDate() + daysFromNow); const day = date.getDay(); if (day === 0) date.setDate(date.getDate() + 1); if (day === 6) date.setDate(date.getDate() + 2); const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function getTomorrowWeekday(): string { return getNextWeekday(1); } async function loginAsAdmin(page: 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 loginAsParent(page: Page) { await page.goto(`${ALPHA_URL}/login`); await page.locator('#email').fill(PARENT_EMAIL); await page.locator('#password').fill(PARENT_PASSWORD); await Promise.all([ page.waitForURL(/\/dashboard/, { timeout: 30000 }), page.getByRole('button', { name: /se connecter/i }).click() ]); } async function addGuardianIfNotLinked(page: Page, studentId: string, parentSearchTerm: string, relationship: string) { await page.goto(`${ALPHA_URL}/admin/students/${studentId}`); await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 }); await expect( page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list')) ).toBeVisible({ timeout: 10000 }); const addButton = page.getByRole('button', { name: /ajouter un parent/i }); if (!(await addButton.isVisible())) return; const sectionText = await page.locator('.guardian-section').textContent(); if (sectionText && sectionText.includes(parentSearchTerm)) return; await addButton.click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 5000 }); const searchInput = dialog.getByRole('combobox', { name: /rechercher/i }); await searchInput.fill(parentSearchTerm); const listbox = dialog.locator('#parent-search-listbox'); await expect(listbox).toBeVisible({ timeout: 10000 }); const option = listbox.locator('[role="option"]').first(); await option.click(); await expect(dialog.getByText(/sélectionné/i)).toBeVisible(); await dialog.getByLabel(/type de relation/i).selectOption(relationship); await dialog.getByRole('button', { name: 'Ajouter' }).click(); await expect( page.locator('.alert-success').or(page.locator('.alert-error')) ).toBeVisible({ timeout: 10000 }); } test.describe('Parent Homework Consultation (Story 5.8)', () => { test.describe.configure({ mode: 'serial', timeout: 60000 }); const urgentDueDate = getTomorrowWeekday(); const futureDueDate = getNextWeekday(10); test.beforeAll(async ({ browser }, testInfo) => { testInfo.setTimeout(120000); try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pools may not exist } // Create 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=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=ParentHW --lastName=TestUser 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 student1Output = execSync( `docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT1_EMAIL} --password=${STUDENT1_PASSWORD} --role=ROLE_ELEVE --firstName=Emma --lastName=ParentHWTest 2>&1`, { encoding: 'utf-8' } ); student1UserId = extractUserId(student1Output); const student2Output = 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 --firstName=Lucas --lastName=ParentHWTest 2>&1`, { encoding: 'utf-8' } ); student2UserId = extractUserId(student2Output); const { schoolId, academicYearId } = resolveDeterministicIds(); // Ensure classes exist 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-PHW-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-PHW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } // Ensure 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-PHW-Maths', 'E2EPHWMAT', '#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-PHW-Français', 'E2EPHWFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already 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 = '${STUDENT1_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `AND c.name = 'E2E-PHW-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-PHW-6B' AND c.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); // Clean up stale homework from previous runs try { runSql( `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` + `(SELECT id FROM school_classes WHERE name IN ('E2E-PHW-6A', 'E2E-PHW-6B') AND tenant_id = '${TENANT_ID}')` ); } catch { // Table may not exist } // Seed homework for both classes // Urgent homework (due tomorrow) for class 6A runSql( `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Devoir urgent maths', 'Exercices urgents', '${urgentDueDate}', 'published', NOW(), NOW() ` + `FROM school_classes c, ` + `(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' 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-PHW-6A' AND c.tenant_id = '${TENANT_ID}'` ); // Future homework for class 6A runSql( `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Rédaction français Emma', 'Écrire une rédaction', '${futureDueDate}', 'published', NOW(), NOW() ` + `FROM school_classes c, ` + `(SELECT id FROM subjects WHERE code = 'E2EPHWFRA' 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-PHW-6A' AND c.tenant_id = '${TENANT_ID}'` ); // Homework for class 6B (Lucas) runSql( `INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Exercices maths Lucas', 'Exercices chapitre 7', '${futureDueDate}', 'published', NOW(), NOW() ` + `FROM school_classes c, ` + `(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' 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-PHW-6B' AND c.tenant_id = '${TENANT_ID}'` ); // Link parent to both students via admin UI const page = await browser.newPage(); await loginAsAdmin(page); await addGuardianIfNotLinked(page, student1UserId, PARENT_EMAIL, 'tuteur'); await addGuardianIfNotLinked(page, student2UserId, PARENT_EMAIL, 'tutrice'); await page.close(); clearCache(); }); // ====================================================================== // AC1: Liste devoirs enfant // ====================================================================== test.describe('AC1: Homework List', () => { test('parent can navigate to homework page via navigation', async ({ page }) => { await loginAsParent(page); const nav = page.locator('.desktop-nav'); await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 }); }); test('parent homework page shows homework list', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); await expect( page.getByRole('heading', { name: /devoirs des enfants/i }) ).toBeVisible({ timeout: 15000 }); // Homework cards should be visible const cards = page.locator('.homework-card'); await expect(cards.first()).toBeVisible({ timeout: 10000 }); }); }); // ====================================================================== // AC2: Vue identique élève (sans marquage "Fait") // ====================================================================== test.describe('AC2: Student-like View Without Done Toggle', () => { test('homework cards do NOT show done toggle checkbox', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); const card = page.locator('.homework-card').first(); await expect(card).toBeVisible({ timeout: 10000 }); // No toggle-done button should exist (privacy) await expect(card.locator('.toggle-done')).toHaveCount(0); }); test('homework cards show title and due date', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); const card = page.locator('.homework-card').first(); await expect(card).toBeVisible({ timeout: 10000 }); // Title visible await expect(card.locator('.card-title')).toBeVisible(); // Due date visible await expect(card.locator('.due-date')).toBeVisible(); }); }); // ====================================================================== // AC3: Vue multi-enfants // ====================================================================== test.describe('AC3: Multi-Child View', () => { test('parent with multiple children sees child selector', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); const childSelector = page.locator('.child-selector'); await expect(childSelector).toBeVisible({ timeout: 10000 }); // Should have "Tous" + 2 children buttons const buttons = childSelector.locator('.child-button'); await expect(buttons).toHaveCount(3); }); test('consolidated view shows homework grouped by child', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); // Wait for data to load const card = page.locator('[data-testid="homework-card"]').first(); await expect(card).toBeVisible({ timeout: 10000 }); // Both children's names should appear as section headers const childNames = page.locator('[data-testid="child-name"]'); await expect(childNames).toHaveCount(2, { timeout: 10000 }); }); test('clicking a specific child filters to their homework', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); const childSelector = page.locator('.child-selector'); await expect(childSelector).toBeVisible({ timeout: 10000 }); // Click on first child (Emma) const buttons = childSelector.locator('.child-button'); await buttons.nth(1).click(); // Wait for data to reload const card = page.locator('.homework-card').first(); await expect(card).toBeVisible({ timeout: 10000 }); // Should no longer show multiple child sections await expect(page.locator('.child-name')).toHaveCount(0, { timeout: 5000 }); }); }); // ====================================================================== // AC4: Mise en évidence urgence // ====================================================================== test.describe('AC4: Urgency Highlight', () => { test('homework due tomorrow shows urgent badge', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); // Find urgent badge — text depends on when test runs relative to seeded date const urgentBadge = page.locator('[data-testid="urgent-badge"]'); await expect(urgentBadge.first()).toBeVisible({ timeout: 5000 }); await expect(urgentBadge.first()).toContainText(/pour demain|aujourd'hui|en retard/i); }); test('urgent homework card has red styling', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); // Urgent card should have the urgent class const urgentCard = page.locator('[data-testid="homework-card"].urgent'); await expect(urgentCard.first()).toBeVisible({ timeout: 5000 }); }); test('urgent homework shows contact teacher link', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); // Contact teacher link should be visible on urgent homework const contactLink = page.locator('[data-testid="contact-teacher"]'); await expect(contactLink.first()).toBeVisible({ timeout: 5000 }); await expect(contactLink.first()).toContainText(/contacter l'enseignant/i); }); test('contact teacher link points to messaging page', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 }); const contactLink = page.locator('[data-testid="contact-teacher"]').first(); await expect(contactLink).toBeVisible({ timeout: 5000 }); // Verify href contains message creation path with proper encoding const href = await contactLink.getAttribute('href'); expect(href).toContain('/messages/new'); expect(href).toContain('to='); expect(href).toContain('subject=Devoir'); }); }); // ====================================================================== // Homework detail // ====================================================================== test.describe('Homework Detail', () => { test('clicking a homework card shows detail view', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); const card = page.locator('.homework-card').first(); await expect(card).toBeVisible({ timeout: 10000 }); await card.click(); await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.detail-title')).toBeVisible(); }); test('back button returns to homework list', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-homework`); const card = page.locator('.homework-card').first(); await expect(card).toBeVisible({ timeout: 10000 }); await card.click(); await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 }); // Click back await page.locator('.back-button').click(); await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 5000 }); }); }); });