Files
Classeo/frontend/e2e/homework.spec.ts
Mathias STRASSER dc2be898d5
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
2026-04-16 09:27:25 +02:00

768 lines
31 KiB
TypeScript

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();
});
});
});