import { test, expect } from '@playwright/test'; import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers'; 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-grades@example.com'; const PARENT_PASSWORD = 'ParentGrades123'; const TEACHER_EMAIL = 'e2e-pg-teacher@example.com'; const TEACHER_PASSWORD = 'TeacherPG123'; const STUDENT_EMAIL = 'e2e-pg-student@example.com'; const STUDENT_PASSWORD = 'StudentPG123'; const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'; let parentId: string; let studentId: string; let classId: string; let subjectId: string; let evalId: string; let periodId: string; function uuid5(name: string): string { return execWithRetry( `docker compose -f "${composeFile}" exec -T php php -r '` + `require "/app/vendor/autoload.php"; ` + `echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","${name}")->toString();` + `' 2>&1` ).trim(); } 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 }); } test.describe('Parent Grade Consultation (Story 6.7)', () => { test.describe.configure({ mode: 'serial' }); test.beforeAll(async () => { // Create users createTestUser( 'ecole-alpha', PARENT_EMAIL, PARENT_PASSWORD, 'ROLE_PARENT --firstName=Marie --lastName=Dupont' ); createTestUser( 'ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF --firstName=Jean --lastName=Martin' ); createTestUser( 'ecole-alpha', STUDENT_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Emma --lastName=Dupont' ); const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID); // Resolve user IDs const parentOutput = execWithRetry( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${PARENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1` ); parentId = parentOutput.match( /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ )![0]!; const studentOutput = execWithRetry( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${STUDENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1` ); studentId = studentOutput.match( /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ )![0]!; // Create deterministic IDs classId = uuid5(`pg-class-${TENANT_ID}`); subjectId = uuid5(`pg-subject-${TENANT_ID}`); evalId = uuid5(`pg-eval-${TENANT_ID}`); // Create class runSql( `INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` + `VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-PG-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); // Create subject runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-PG-Mathématiques', 'E2EPGMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); // Assign student to class runSql( `INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING` ); // Link parent to student runSql( `INSERT INTO student_guardians (id, tenant_id, student_id, guardian_id, relationship_type, created_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${parentId}', 'mère', NOW()) ON CONFLICT DO NOTHING` ); // Create teacher assignment 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, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + `FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + `ON CONFLICT DO NOTHING` ); // Create published evaluation (published 48h ago so delay is passed) runSql( `INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` + `SELECT '${evalId}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'DS Maths Parent', '2026-03-01', 20, 2.0, 'published', NOW() - INTERVAL '48 hours', NOW() - INTERVAL '48 hours', NOW() ` + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + `ON CONFLICT (id) DO NOTHING` ); // Insert grade for student runSql( `INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) ` + `SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId}', '${studentId}', 15.5, 'graded', u.id, NOW(), NOW(), 'Bon travail' ` + `FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` + `ON CONFLICT (evaluation_id, student_id) DO NOTHING` ); // Insert class statistics runSql( `INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` + `VALUES ('${evalId}', 13.5, 7.0, 18.0, 13.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING` ); // Find academic period const periodOutput = execWithRetry( `docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM academic_periods WHERE tenant_id='${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1" 2>&1` ); const periodMatch = periodOutput.match( /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ ); periodId = periodMatch ? periodMatch[0]! : uuid5(`pg-period-${TENANT_ID}`); // Insert student averages runSql( `INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${subjectId}', '${periodId}', 15.5, 1, NOW()) ` + `ON CONFLICT (student_id, subject_id, period_id) DO NOTHING` ); runSql( `INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${periodId}', 15.5, NOW()) ` + `ON CONFLICT (student_id, period_id) DO NOTHING` ); clearCache(); }); // ========================================================================= // AC2: Parent can see child's grades and averages // ========================================================================= test('AC2: parent navigates to grades page', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); await expect(page.getByRole('heading', { name: 'Notes des enfants' })).toBeVisible({ timeout: 15000 }); }); test('AC2: parent sees child selector', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); // Child selector should be visible with the child's name await expect(page.getByText('Emma Dupont')).toBeVisible({ timeout: 15000 }); }); test("AC2: parent sees child's grade card", async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); // Wait for grades to load (single child auto-selected) await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 }); await expect(page.locator('.grade-value', { hasText: '15.5/20' }).first()).toBeVisible(); }); // ========================================================================= // AC4: Subject detail with class statistics // ========================================================================= test('AC4: parent sees class statistics on grade card', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 }); await expect(page.getByText(/Moy\. classe/)).toBeVisible(); }); test('AC4: parent opens subject detail modal', async ({ page }) => { await loginAsParent(page); await page.goto(`${ALPHA_URL}/dashboard/parent-grades`); // Wait for grade cards to appear await expect(page.getByText('DS Maths Parent')).toBeVisible({ timeout: 15000 }); // Click on a grade card to open subject detail await page.getByRole('button', { name: /DS Maths Parent/ }).click(); // Modal should appear with subject name and grade details const modal = page.getByRole('dialog'); await expect(modal).toBeVisible({ timeout: 5000 }); await expect(modal.locator('.grade-value', { hasText: '15.5/20' }).first()).toBeVisible(); }); // ========================================================================= // Navigation // ========================================================================= test('navigation: parent sees Notes link in nav', async ({ page }) => { await loginAsParent(page); await expect(page.getByRole('link', { name: 'Notes' })).toBeVisible({ timeout: 15000 }); }); });