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.
This commit is contained in:
361
frontend/e2e/homework-exception.spec.ts
Normal file
361
frontend/e2e/homework-exception.spec.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
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()`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// NOTE: Do NOT call clearRules() here — it deletes rules for the shared
|
||||
// tenant and breaks other spec files (homework-rules-warning) running in
|
||||
// parallel. Each test seeds its own rules via seedHardRules() which uses
|
||||
// ON CONFLICT DO UPDATE, so prior state is irrelevant.
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,13 +112,7 @@ function seedHardRules() {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -168,7 +162,10 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
clearRules();
|
||||
// NOTE: Do NOT call clearRules() here — it deletes rules for the shared
|
||||
// tenant and creates race conditions with other spec files running in
|
||||
// parallel. Each test seeds its own rules via seedHardRules() which uses
|
||||
// ON CONFLICT DO UPDATE, so prior state is irrelevant.
|
||||
clearCache();
|
||||
});
|
||||
|
||||
@@ -276,7 +273,7 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => {
|
||||
// AC3: Exception request information
|
||||
// ============================================================================
|
||||
test.describe('AC3: Exception request information', () => {
|
||||
test('shows exception contact information in blocking modal', async ({ page }) => {
|
||||
test('shows exception request button in blocking modal', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
@@ -287,8 +284,8 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => {
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Exception information visible
|
||||
await expect(blockedDialog.getByText(/exception.*contactez/i)).toBeVisible();
|
||||
// Exception request button visible (Story 5.6 replaced static text with a real button)
|
||||
await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -155,7 +155,10 @@ test.describe('Homework Rules - Soft Warning (Story 5.4)', () => {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
clearRules();
|
||||
// NOTE: Do NOT call clearRules() here — it deletes rules for the shared
|
||||
// tenant and creates race conditions with other spec files running in
|
||||
// parallel. Each test seeds its own rules via seedSoftRules() which uses
|
||||
// ON CONFLICT DO UPDATE, so prior state is irrelevant.
|
||||
clearCache();
|
||||
});
|
||||
|
||||
@@ -335,7 +338,11 @@ test.describe('Homework Rules - Soft Warning (Story 5.4)', () => {
|
||||
});
|
||||
|
||||
test('no badge on homework created without override', async ({ page }) => {
|
||||
clearRules();
|
||||
// Disable rules instead of deleting them to avoid race conditions
|
||||
// with other spec files running in parallel on the same tenant.
|
||||
runSql(
|
||||
`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`,
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
|
||||
Reference in New Issue
Block a user