Files
Classeo/frontend/e2e/homework-exception.spec.ts
Mathias STRASSER 9b868ae5c4
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: Permettre aux enseignants de contourner les règles de devoirs avec justification
Akeneo permet de configurer des règles de devoirs en mode Hard qui bloquent
totalement la création. Or certains cas légitimes (sorties scolaires, événements
exceptionnels) nécessitent de passer outre ces règles. Sans mécanisme d'exception,
l'enseignant est bloqué et doit contacter manuellement la direction.

Cette implémentation ajoute un flux complet d'exception : l'enseignant justifie
sa demande (min 20 caractères), le devoir est créé immédiatement, et la direction
est notifiée par email. Le handler vérifie côté serveur que les règles sont
réellement bloquantes avant d'accepter l'exception, empêchant toute fabrication
de fausses exceptions via l'API. La direction dispose d'un rapport filtrable
par période, enseignant et type de règle.
2026-03-20 11:18:04 +01:00

366 lines
14 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-exception-teacher@example.com';
const TEACHER_PASSWORD = 'Exception123';
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! };
}
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
}
}
function seedHardRules() {
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, 'hard', true, NOW(), NOW()) ` +
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`,
);
}
function clearRules() {
try {
runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
}
async function openCreateAndFillForm(page: import('@playwright/test').Page, title: string, daysFromNow: number) {
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(daysFromNow);
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(title);
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
}
test.describe('Homework Exception Request (Story 5.6)', () => {
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 --firstName=Jean --lastName=Exception 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-EX-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-EX-Maths', 'E2EEXM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
);
} catch {
// May already exist
}
seedTeacherAssignments();
});
test.describe.configure({ mode: 'serial' });
test.beforeEach(async () => {
try {
runSql(
`DELETE FROM homework_rule_exceptions WHERE tenant_id = '${TENANT_ID}'`,
);
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 {
// Tables may not exist
}
clearRules();
clearCache();
});
// ============================================================================
// AC1: Exception request form
// ============================================================================
test.describe('AC1: Exception request form', () => {
test('shows "Demander une exception" button in blocking modal', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir exception test', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible();
});
test('clicking exception button opens justification form', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir exception form', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
// Exception request dialog appears
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
await expect(exceptionDialog.getByText(/demander une exception/i)).toBeVisible();
await expect(exceptionDialog.locator('#exception-justification')).toBeVisible();
});
});
// ============================================================================
// AC2: Justification required (min 20 chars)
// ============================================================================
test.describe('AC2: Justification validation', () => {
test('submit button disabled when justification too short', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir justif short', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
// Type less than 20 characters
await exceptionDialog.locator('#exception-justification').fill('Court');
// Submit button should be disabled
await expect(exceptionDialog.getByRole('button', { name: /créer avec exception/i })).toBeDisabled();
});
test('homework created immediately after valid justification', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir exception créé', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
// Type valid justification (>= 20 chars)
await exceptionDialog
.locator('#exception-justification')
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
// Submit
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
// Homework appears in the list
await expect(page.getByText('Devoir exception créé')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC4: Exception badge
// ============================================================================
test.describe('AC4: Exception marking', () => {
test('homework with exception shows exception badge', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir avec badge', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
await exceptionDialog
.locator('#exception-justification')
.fill('Justification suffisamment longue pour être valide.');
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
// Wait for homework to appear
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
// Exception badge visible (⚠ Exception text or rule override badge)
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
await expect(card.locator('.badge-rule-exception, .badge-rule-override')).toBeVisible();
});
test('exception badge tooltip describes the exception for justification viewing', async ({ page }) => {
seedHardRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir tooltip test', 2);
const blockedDialog = page.getByRole('alertdialog');
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
const exceptionDialog = page.getByRole('dialog');
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
await exceptionDialog
.locator('#exception-justification')
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
await expect(page.getByText('Devoir tooltip test')).toBeVisible({ timeout: 10000 });
// Badge has descriptive title attribute for justification consultation
const card = page.locator('.homework-card', { hasText: 'Devoir tooltip test' });
const badge = card.locator('.badge-rule-exception');
await expect(badge).toBeVisible();
await expect(badge).toHaveAttribute('title', /exception/i);
await expect(badge).toContainText('Exception');
});
});
// ============================================================================
// AC5: Direction exceptions report
// ============================================================================
// AC5 (Direction exceptions report) is covered by unit tests
// (GetHomeworkExceptionsReportHandlerTest) because E2E testing requires
// multi-tenant admin login which is not reliably testable in parallel mode.
// ============================================================================
// AC6: Soft mode - no justification needed
// ============================================================================
test.describe('AC6: Soft mode without justification', () => {
test('soft mode does not show exception request button', async ({ page }) => {
// Configure soft mode
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()`,
);
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await openCreateAndFillForm(page, 'Devoir soft mode', 2);
// Warning modal appears (not blocking)
const warningModal = page.getByRole('alertdialog');
await expect(warningModal).toBeVisible({ timeout: 10000 });
await expect(warningModal.getByText(/avertissement/i)).toBeVisible();
// "Continuer malgré tout" visible (soft mode allows bypass)
await expect(warningModal.getByRole('button', { name: /continuer malgré tout/i })).toBeVisible();
// No exception request button
await expect(warningModal.getByRole('button', { name: /demander une exception/i })).not.toBeVisible();
});
});
});