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 TEACHER_EMAIL = 'e2e-rules-warn-teacher@example.com'; const TEACHER_PASSWORD = 'RulesWarn123'; 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! }; } /** * Returns a weekday date string (YYYY-MM-DD), N days from now. * Skips weekends. */ 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}`; } 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: 30000 }), page.getByRole('button', { name: /se connecter/i }).click(), ]); } async function navigateToHomework(page: import('@playwright/test').Page) { await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`); await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 }); } function seedTeacherAssignments() { const { academicYearId } = resolveDeterministicIds(); try { 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 } } /** * Configure homework rules with minimum_delay of 7 days in soft mode * so that a homework due in 1-2 days triggers a warning. */ function seedSoftRules() { // Use escaped quotes for JSON inside double-quoted shell command const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]'; runSql( `INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'soft', true, NOW(), NOW()) ` + `ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()`, ); } function clearRules() { try { runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`); } catch { // Table may not exist } } test.describe('Homework Rules - Soft Warning (Story 5.4)', () => { test.beforeAll(async () => { // 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(); 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-RW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`, ); runSql( `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-RW-Maths', 'E2ERWM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`, ); } catch { // May already exist } seedTeacherAssignments(); }); test.beforeEach(async () => { try { runSql( `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`, ); } catch { // Table may not exist } clearRules(); clearCache(); }); // ============================================================================ // AC1 + AC2: Warning displayed with rule description // ============================================================================ test.describe('AC1 + AC2: Warning display', () => { test('shows warning modal when due date violates soft rule', async ({ page }) => { seedSoftRules(); clearCache(); await loginAsTeacher(page); await navigateToHomework(page); // Open create modal await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); // Fill form with near due date (2 days → violates 7-day minimum_delay) const nearDate = getNextWeekday(2); await page.locator('#hw-class').selectOption({ index: 1 }); await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir test warning'); await page.locator('#hw-due-date').fill(nearDate); // Submit await page.getByRole('button', { name: /créer le devoir/i }).click(); // Warning modal appears const warningDialog = page.getByRole('alertdialog'); await expect(warningDialog).toBeVisible({ timeout: 10000 }); await expect(warningDialog.getByText(/ne respecte pas les règles/i)).toBeVisible(); await expect(warningDialog.getByText(/au moins/i)).toBeVisible(); await expect(warningDialog.getByText(/votre choix sera enregistré/i)).toBeVisible(); }); test('no warning when rules not configured', async ({ page }) => { clearRules(); clearCache(); await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); const nearDate = getNextWeekday(2); await page.locator('#hw-class').selectOption({ index: 1 }); await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir sans rules'); await page.locator('#hw-due-date').fill(nearDate); await page.getByRole('button', { name: /créer le devoir/i }).click(); // No warning, homework created directly await expect(page.getByRole('alertdialog')).not.toBeVisible({ timeout: 3000 }); await expect(page.getByText('Devoir sans rules')).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC3: Continue despite warning → homework created, event traced // ============================================================================ test.describe('AC3: Continue despite warning', () => { test('creates homework when choosing to continue', async ({ page }) => { seedSoftRules(); clearCache(); await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); const nearDate = getNextWeekday(2); await page.locator('#hw-class').selectOption({ index: 1 }); await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir continue warning'); await page.locator('#hw-due-date').fill(nearDate); await page.getByRole('button', { name: /créer le devoir/i }).click(); // Warning modal appears const warningDialog = page.getByRole('alertdialog'); await expect(warningDialog).toBeVisible({ timeout: 10000 }); // Click continue await warningDialog.getByRole('button', { name: /continuer malgré tout/i }).click(); // Warning modal closes, homework appears in list await expect(warningDialog).not.toBeVisible({ timeout: 5000 }); await expect(page.getByText('Devoir continue warning')).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC4: Modify date → calendar reopens // ============================================================================ test.describe('AC4: Modify date', () => { test('reopens create form when choosing to modify date', async ({ page }) => { seedSoftRules(); clearCache(); await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); const nearDate = getNextWeekday(2); await page.locator('#hw-class').selectOption({ index: 1 }); await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir modifier date'); await page.locator('#hw-due-date').fill(nearDate); await page.getByRole('button', { name: /créer le devoir/i }).click(); // Warning modal appears const warningDialog = page.getByRole('alertdialog'); await expect(warningDialog).toBeVisible({ timeout: 10000 }); // Click modify date await warningDialog.getByRole('button', { name: /modifier la date/i }).click(); // Warning closes, create form reopens await expect(warningDialog).not.toBeVisible({ timeout: 3000 }); const createDialog = page.getByRole('dialog'); await expect(createDialog).toBeVisible(); await expect(createDialog.locator('#hw-due-date')).toBeVisible(); // Change to a compliant date (15 days from now) const farDate = getNextWeekday(15); await page.locator('#hw-due-date').fill(farDate); // Submit again — should succeed without warning await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByText('Devoir modifier date')).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC5: History badge for overridden homeworks // ============================================================================ test.describe('AC5: Discreet badge in history', () => { test('shows warning badge on homework created with override', async ({ page }) => { seedSoftRules(); clearCache(); await loginAsTeacher(page); await navigateToHomework(page); // Create homework with override await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); const nearDate = getNextWeekday(2); await page.locator('#hw-class').selectOption({ index: 1 }); await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir avec badge'); await page.locator('#hw-due-date').fill(nearDate); await page.getByRole('button', { name: /créer le devoir/i }).click(); const warningDialog = page.getByRole('alertdialog'); await expect(warningDialog).toBeVisible({ timeout: 10000 }); await warningDialog.getByRole('button', { name: /continuer malgré tout/i }).click(); // Homework card should have the warning badge await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 }); const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' }); await expect(card.locator('.badge-rule-override')).toBeVisible(); }); test('no badge on homework created without override', async ({ page }) => { clearRules(); clearCache(); await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); const farDate = getNextWeekday(15); await page.locator('#hw-class').selectOption({ index: 1 }); await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir normal'); await page.locator('#hw-due-date').fill(farDate); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByText('Devoir normal')).toBeVisible({ timeout: 10000 }); const card = page.locator('.homework-card', { hasText: 'Devoir normal' }); await expect(card.locator('.badge-rule-override')).not.toBeVisible(); }); }); });