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-homework-teacher@example.com'; const TEACHER_PASSWORD = 'HomeworkTest123'; 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 future weekday date string (YYYY-MM-DD). * Skips weekends to satisfy DueDateValidator. */ function getNextWeekday(daysFromNow: number): string { const date = new Date(); date.setDate(date.getDate() + daysFromNow); // Skip to Monday if weekend const day = date.getDay(); if (day === 0) date.setDate(date.getDate() + 1); // Sunday → Monday if (day === 6) date.setDate(date.getDate() + 2); // Saturday → Monday // Use local date components to avoid UTC timezone shift from toISOString() 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 page.getByRole('button', { name: /se connecter/i }).click(); await page.waitForURL(/\/dashboard/, { timeout: 60000 }); } 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 } } test.describe('Homework Management (Story 5.1)', () => { test.beforeAll(async () => { // Clear rate limiter to prevent login throttling across serial tests try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pool may not exist } // 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' } ); // Ensure classes and subject exist 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-HW-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-HW-6B', '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-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } seedTeacherAssignments(); clearCache(); }); test.beforeEach(async () => { // Clear rate limiter to prevent login throttling across tests try { execSync( `docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter --env=dev 2>&1`, { encoding: 'utf-8' } ); } catch { // Cache pool may not exist } // Clean up homework data (homework_submissions has NO CASCADE on homework_id, // so we must delete submissions first) try { runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`); } catch { /* Table may not exist */ } try { runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id 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 */ } 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 } // Disable any homework rules left by other test files (homework-rules-warning, // homework-rules-hard) to prevent rule warnings/blocks in duplicate tests. try { runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`); } catch { /* Table may not exist */ } // Clear school calendar entries that may block dates (Vacances de Printemps, etc.) try { runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`); } catch { /* Table may not exist */ } // Re-ensure data exists 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-HW-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-HW-6B', '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-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } seedTeacherAssignments(); clearCache(); }); // ============================================================================ // Navigation // ============================================================================ test.describe('Navigation', () => { test('homework link appears in teacher navigation', async ({ page }) => { await loginAsTeacher(page); const nav = page.locator('.desktop-nav'); await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 }); }); test('can navigate to homework page', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible(); }); }); // ============================================================================ // Empty State // ============================================================================ test.describe('Empty State', () => { test('shows empty state when no homework exists', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await expect(page.getByText(/aucun devoir/i)).toBeVisible({ timeout: 20000 }); }); }); // ============================================================================ // AC1: Create Homework // ============================================================================ test.describe('AC1: Create Homework', () => { test('can create a new homework', async ({ page }) => { 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: 10000 }); // Select class const classSelect = page.locator('#hw-class'); await expect(classSelect).toBeVisible(); await classSelect.selectOption({ index: 1 }); // Select subject (AC2: filtered by class) const subjectSelect = page.locator('#hw-subject'); await expect(subjectSelect).toBeEnabled(); await subjectSelect.selectOption({ index: 1 }); // Fill title await page.locator('#hw-title').fill('Exercices chapitre 5'); // Fill description (TipTap initializes asynchronously) const editorContent = page.locator('.modal .rich-text-content'); await expect(editorContent).toBeVisible({ timeout: 10000 }); await editorContent.click(); await editorContent.pressSequentially('Pages 42-45, exercices 1 à 10'); // Set due date (next weekday, at least 2 days from now) await page.locator('#hw-due-date').fill(getNextWeekday(3)); // Submit await page.getByRole('button', { name: /créer le devoir/i }).click(); // Modal closes and homework appears in list await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText('Exercices chapitre 5')).toBeVisible({ timeout: 10000 }); }); test('cancel closes the modal without creating', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.getByRole('button', { name: /annuler/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC2: Subject Filtering // ============================================================================ test.describe('AC2: Subject Filtering', () => { test('subject dropdown is disabled until class is selected', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); // Subject should be disabled initially await expect(page.locator('#hw-subject')).toBeDisabled(); // Select class await page.locator('#hw-class').selectOption({ index: 1 }); // Subject should now be enabled await expect(page.locator('#hw-subject')).toBeEnabled(); }); }); // ============================================================================ // AC5: Published Homework Appears in List // ============================================================================ test.describe('AC5: Published Homework', () => { test('created homework appears in list with correct info', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); // Create a homework await page.getByRole('button', { name: /nouveau devoir/i }).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir de mathématiques'); await page.locator('#hw-due-date').fill(getNextWeekday(5)); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); // Verify homework card shows await expect(page.getByText('Devoir de mathématiques')).toBeVisible({ timeout: 10000 }); await expect(page.getByText(/publié/i)).toBeVisible(); }); }); // ============================================================================ // AC6: Edit Homework // ============================================================================ test.describe('AC6: Edit Homework', () => { test('can modify an existing homework', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); // First create a homework await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir à modifier'); await page.locator('#hw-due-date').fill(getNextWeekday(5)); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText('Devoir à modifier')).toBeVisible({ timeout: 10000 }); // Click edit await page.getByRole('button', { name: /modifier/i }).first().click(); const editDialog = page.getByRole('dialog'); await expect(editDialog).toBeVisible({ timeout: 10000 }); // Change title await page.locator('#edit-title').clear(); await page.locator('#edit-title').fill('Devoir modifié'); // Save await page.getByRole('button', { name: /enregistrer/i }).click(); await expect(editDialog).not.toBeVisible({ timeout: 10000 }); // Verify updated title await expect(page.getByText('Devoir modifié')).toBeVisible({ timeout: 10000 }); }); test('can delete an existing homework', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); // First create a homework await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir à supprimer'); await page.locator('#hw-due-date').fill(getNextWeekday(5)); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText('Devoir à supprimer')).toBeVisible({ timeout: 10000 }); // Click delete await page.getByRole('button', { name: /supprimer/i }).first().click(); // Confirm in alertdialog const confirmDialog = page.getByRole('alertdialog'); await expect(confirmDialog).toBeVisible({ timeout: 5000 }); await expect(page.getByText(/êtes-vous sûr/i)).toBeVisible(); await confirmDialog.getByRole('button', { name: /supprimer/i }).click(); await expect(confirmDialog).not.toBeVisible({ timeout: 10000 }); // Homework should be gone from the list (or show as deleted) await expect(page.getByText('Devoir à supprimer')).not.toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // Date Validation // ============================================================================ test.describe('Date Validation', () => { test('due date input has min attribute set to tomorrow', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); const dueDateInput = page.locator('#hw-due-date'); const minValue = await dueDateInput.getAttribute('min'); const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const ey = tomorrow.getFullYear(); const em = String(tomorrow.getMonth() + 1).padStart(2, '0'); const ed = String(tomorrow.getDate()).padStart(2, '0'); expect(minValue).toBe(`${ey}-${em}-${ed}`); }); test('backend rejects a past due date', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Devoir date passée'); const editorVal = page.locator('.modal .rich-text-content'); await expect(editorVal).toBeVisible({ timeout: 10000 }); await editorVal.click(); await editorVal.pressSequentially('Test validation'); // Set a past weekday — must be Mon-Fri to avoid frontend weekend validation const pastDay = new Date(); do { pastDay.setDate(pastDay.getDate() - 1); } while (pastDay.getDay() === 0 || pastDay.getDay() === 6); const y = pastDay.getFullYear(); const m = String(pastDay.getMonth() + 1).padStart(2, '0'); const d = String(pastDay.getDate()).padStart(2, '0'); const pastDate = `${y}-${m}-${d}`; await page.locator('#hw-due-date').fill(pastDate); // Bypass HTML native validation (min attribute) to test backend validation await page.locator('form.modal-body').evaluate((el) => el.setAttribute('novalidate', '')); await page.getByRole('button', { name: /créer le devoir/i }).click(); // Backend should reject — an error alert appears await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 }); }); }); // ============================================================================ // AC: Duplicate Homework (Story 5.2) // ============================================================================ test.describe('Story 5.2: Duplicate Homework', () => { test.beforeAll(async () => { // Ensure a second class exists for duplication 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-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING` ); } catch { // May already exist } seedTeacherAssignments(); clearCache(); }); async function createHomework(page: import('@playwright/test').Page, title: string) { await createHomeworkInClass(page, title, { index: 1 }); } async function createHomeworkInClass( page: import('@playwright/test').Page, title: string, classOption: { index: number } | { label: string }, ) { await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption(classOption); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill(title); await page.locator('#hw-due-date').fill(getNextWeekday(5)); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText(title)).toBeVisible({ timeout: 10000 }); } test('duplicate button is visible on homework card', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir dupliquer test'); await expect(page.getByRole('button', { name: /dupliquer/i })).toBeVisible(); }); test('opens duplicate modal with class selection', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir à dupliquer'); await page.getByRole('button', { name: /dupliquer/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); await expect(dialog.getByText(/dupliquer le devoir/i)).toBeVisible(); await expect(dialog.getByText('Devoir à dupliquer')).toBeVisible(); }); test('shows target classes checkboxes excluding source class', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir classes cibles'); await page.getByRole('button', { name: /dupliquer/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Should show checkboxes for target classes const checkboxes = dialog.locator('input[type="checkbox"]'); await expect(checkboxes.first()).toBeVisible({ timeout: 5000 }); }); test('can duplicate homework to a target class', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir duplication réussie'); await page.getByRole('button', { name: /dupliquer/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Select first target class const firstCheckbox = dialog.locator('input[type="checkbox"]').first(); await firstCheckbox.check(); // Click duplicate button await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); // Homework should now appear twice (original + duplicate) await expect(page.getByText('Devoir duplication réussie')).toHaveCount(2, { timeout: 10000 }); }); test('duplicate button is disabled when no class selected', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir sans sélection'); await page.getByRole('button', { name: /dupliquer/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Duplicate button should be disabled const duplicateBtn = dialog.getByRole('button', { name: /dupliquer/i }).last(); await expect(duplicateBtn).toBeDisabled(); }); test('cancel closes the duplicate modal', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir annulation dupli'); await page.getByRole('button', { name: /dupliquer/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); await dialog.getByRole('button', { name: /annuler/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 5000 }); }); test('can duplicate homework to multiple classes', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir multi-dupli'); await page.getByRole('button', { name: /dupliquer/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Select ALL available checkboxes (at least 2 target classes) const checkboxes = dialog.locator('input[type="checkbox"]'); const count = await checkboxes.count(); expect(count).toBeGreaterThanOrEqual(1); for (let i = 0; i < count; i++) { await checkboxes.nth(i).check(); } // Verify button text reflects the count const duplicateButton = dialog.getByRole('button', { name: new RegExp(`dupliquer \\(${count} classes?\\)`, 'i') }); await expect(duplicateButton).toBeVisible(); await expect(duplicateButton).toBeEnabled(); // Click duplicate await duplicateButton.click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); // Verify the homework title appears count+1 times (original + duplicates) await expect(page.getByText('Devoir multi-dupli')).toHaveCount(count + 1, { timeout: 10000 }); }); test('can customize due date per target class', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir date custom'); await page.getByRole('button', { name: /dupliquer/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Select the first checkbox const firstCheckbox = dialog.locator('input[type="checkbox"]').first(); await firstCheckbox.check(); // A date input should appear after checking the checkbox const dueDateInput = dialog.locator('input[type="date"]').first(); await expect(dueDateInput).toBeVisible({ timeout: 5000 }); // Change the due date to a custom value await dueDateInput.fill(getNextWeekday(10)); // Click duplicate await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); // Verify the homework appears twice (original + 1 duplicate) await expect(page.getByText('Devoir date custom')).toHaveCount(2, { timeout: 10000 }); }); test('shows validation errors per class when duplication rules fail', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); await createHomework(page, 'Devoir validation règles'); await page.getByRole('button', { name: /dupliquer/i }).first().click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10000 }); // Select first target class const firstCheckbox = dialog.locator('input[type="checkbox"]').first(); await firstCheckbox.check(); // Intercept the POST duplicate API call to simulate a validation error await page.route( '**/api/homework/*/duplicate', async (route, request) => { // Extract targetClassIds from the request body const body = request.postDataJSON(); const targetClassIds: string[] = body.targetClassIds ?? []; await route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'Validation échouée pour certaines classes.', results: targetClassIds.map((classId: string) => ({ classId, valid: false, error: "L'enseignant n'est pas affecté à cette classe pour cette matière.", })), }), }); }, ); // Click duplicate await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click(); // Verify: error alert appears in the modal await expect(dialog.locator('.alert-error')).toBeVisible({ timeout: 10000 }); await expect(dialog.getByText(/certaines classes ne passent pas la validation/i)).toBeVisible(); // Verify: validation error message appears next to the class await expect(dialog.locator('.validation-error')).toBeVisible(); await expect( dialog.getByText(/l'enseignant n'est pas affecté à cette classe/i), ).toBeVisible(); // Verify: modal stays open (not closed on validation error) await expect(dialog).toBeVisible(); }); test('can filter homework list by class', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); // Create homework in two different classes await createHomeworkInClass(page, 'Devoir classe A', { label: 'E2E-HW-6A' }); await createHomeworkInClass(page, 'Devoir classe B', { label: 'E2E-HW-6B' }); // Both visible initially await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); await expect(page.getByText('Devoir classe B')).toBeVisible({ timeout: 5000 }); // Filter by class A — class B homework must disappear const classFilter = page.locator('select[aria-label="Filtrer par classe"]'); await classFilter.selectOption({ label: 'E2E-HW-6A' }); await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); await expect(page.getByText('Devoir classe B')).not.toBeVisible({ timeout: 5000 }); // Reset — both visible again await classFilter.selectOption({ index: 0 }); await expect(page.getByText('Devoir classe A')).toBeVisible({ timeout: 5000 }); await expect(page.getByText('Devoir classe B')).toBeVisible({ timeout: 5000 }); }); }); // ============================================================================ // Partial Update // ============================================================================ test.describe('Partial Update', () => { test('can update only the title without changing other fields', async ({ page }) => { await loginAsTeacher(page); await navigateToHomework(page); const dueDate = getNextWeekday(5); // Create a homework with description await page.getByRole('button', { name: /nouveau devoir/i }).click(); await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 }); await page.locator('#hw-class').selectOption({ index: 1 }); await page.locator('#hw-subject').selectOption({ index: 1 }); await page.locator('#hw-title').fill('Titre original'); const editorEdit = page.locator('.modal .rich-text-content'); await expect(editorEdit).toBeVisible({ timeout: 10000 }); await editorEdit.click(); await editorEdit.pressSequentially('Description inchangée'); await page.locator('#hw-due-date').fill(dueDate); await page.getByRole('button', { name: /créer le devoir/i }).click(); await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 }); await expect(page.getByText('Titre original')).toBeVisible({ timeout: 10000 }); // Open edit modal await page.getByRole('button', { name: /modifier/i }).first().click(); const editDialog = page.getByRole('dialog'); await expect(editDialog).toBeVisible({ timeout: 10000 }); // Verify pre-filled values (TipTap may take time to initialize with content) await expect(page.locator('.modal .rich-text-content')).toBeVisible({ timeout: 10000 }); await expect(page.locator('.modal .rich-text-content')).toContainText('Description inchangée'); await expect(page.locator('#edit-due-date')).toHaveValue(dueDate); // Change only the title await page.locator('#edit-title').clear(); await page.locator('#edit-title').fill('Titre mis à jour'); await page.getByRole('button', { name: /enregistrer/i }).click(); await expect(editDialog).not.toBeVisible({ timeout: 10000 }); // Verify title changed await expect(page.getByText('Titre mis à jour')).toBeVisible({ timeout: 10000 }); await expect(page.getByText('Titre original')).not.toBeVisible(); // Verify description still visible await expect(page.getByText('Description inchangée')).toBeVisible(); }); }); });