Files
Classeo/frontend/e2e/homework-rules-warning.spec.ts
Mathias STRASSER b7dc27f2a5
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: Calculer automatiquement les moyennes après chaque saisie de notes
Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
2026-04-04 02:25:00 +02:00

381 lines
16 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-rules-warn-teacher@example.com';
const TEACHER_PASSWORD = 'RulesWarn123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' },
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' },
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' },
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
/**
* Returns a weekday date string (YYYY-MM-DD), N days from now.
* Skips weekends.
*/
function getNextWeekday(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
const day = date.getDay();
if (day === 0) date.setDate(date.getDate() + 1);
if (day === 6) date.setDate(date.getDate() + 2);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click(),
]);
}
async function navigateToHomework(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
}
function seedTeacherAssignments() {
const { academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.tenant_id = '${TENANT_ID}' ` +
`AND s.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`,
);
} catch {
// Table may not exist
}
}
/**
* Configure homework rules with minimum_delay of 7 days in soft mode
* so that a homework due in 1-2 days triggers a warning.
*/
function seedSoftRules() {
// Use escaped quotes for JSON inside double-quoted shell command
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]';
runSql(
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'soft', true, NOW(), NOW()) ` +
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()`,
);
}
function clearRules() {
try {
runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
}
test.describe('Homework Rules - Soft Warning (Story 5.4)', () => {
test.beforeAll(async () => {
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' },
);
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-RW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-RW-Maths', 'E2ERWM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
);
} catch {
// May already exist
}
seedTeacherAssignments();
});
test.beforeEach(async () => {
// homework_submissions has NO CASCADE on homework_id — 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
}
// 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 */ }
// 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();
});
// ============================================================================
// AC1 + AC2: Warning displayed with rule description
// ============================================================================
test.describe('AC1 + AC2: Warning display', () => {
test('shows warning modal when due date violates soft rule', async ({ page }) => {
seedSoftRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
// Open create modal
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
// Fill form with near due date (2 days → violates 7-day minimum_delay)
const nearDate = getNextWeekday(2);
await page.locator('#hw-class').selectOption({ index: 1 });
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir test warning');
await page.locator('#hw-due-date').fill(nearDate);
// Submit
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Warning modal appears
const warningDialog = page.getByRole('alertdialog');
await expect(warningDialog).toBeVisible({ timeout: 10000 });
await expect(warningDialog.getByText(/ne respecte pas les règles/i)).toBeVisible();
await expect(warningDialog.getByText(/au moins/i)).toBeVisible();
await expect(warningDialog.getByText(/votre choix sera enregistré/i)).toBeVisible();
});
test('no warning when rules not configured', async ({ page }) => {
clearRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(2);
await page.locator('#hw-class').selectOption({ index: 1 });
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir sans rules');
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
// No warning, homework created directly
await expect(page.getByRole('alertdialog')).not.toBeVisible({ timeout: 3000 });
await expect(page.getByText('Devoir sans rules')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC3: Continue despite warning → homework created, event traced
// ============================================================================
test.describe('AC3: Continue despite warning', () => {
test('creates homework when choosing to continue', async ({ page }) => {
seedSoftRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(2);
await page.locator('#hw-class').selectOption({ index: 1 });
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir continue warning');
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Warning modal appears
const warningDialog = page.getByRole('alertdialog');
await expect(warningDialog).toBeVisible({ timeout: 10000 });
// Click continue
await warningDialog.getByRole('button', { name: /continuer malgré tout/i }).click();
// Warning modal closes, homework appears in list
await expect(warningDialog).not.toBeVisible({ timeout: 5000 });
await expect(page.getByText('Devoir continue warning')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC4: Modify date → calendar reopens
// ============================================================================
test.describe('AC4: Modify date', () => {
test('reopens create form when choosing to modify date', async ({ page }) => {
seedSoftRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(2);
await page.locator('#hw-class').selectOption({ index: 1 });
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir modifier date');
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
// Warning modal appears
const warningDialog = page.getByRole('alertdialog');
await expect(warningDialog).toBeVisible({ timeout: 10000 });
// Click modify date
await warningDialog.getByRole('button', { name: /modifier la date/i }).click();
// Warning closes, create form reopens (state transition may take time)
await expect(warningDialog).not.toBeVisible({ timeout: 5000 });
const createDialog = page.getByRole('dialog');
await expect(createDialog).toBeVisible({ timeout: 10000 });
await expect(createDialog.locator('#hw-due-date')).toBeVisible({ timeout: 5000 });
// Change to a compliant date (15 days from now)
const farDate = getNextWeekday(15);
await page.locator('#hw-due-date').fill(farDate);
// Submit again — should succeed without warning
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByText('Devoir modifier date')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC5: History badge for overridden homeworks
// ============================================================================
test.describe('AC5: Discreet badge in history', () => {
test('shows warning badge on homework created with override', async ({ page }) => {
seedSoftRules();
clearCache();
await loginAsTeacher(page);
await navigateToHomework(page);
// Create homework with override
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const nearDate = getNextWeekday(2);
await page.locator('#hw-class').selectOption({ index: 1 });
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir avec badge');
await page.locator('#hw-due-date').fill(nearDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
const warningDialog = page.getByRole('alertdialog');
await expect(warningDialog).toBeVisible({ timeout: 10000 });
await warningDialog.getByRole('button', { name: /continuer malgré tout/i }).click();
// Homework card should have the warning badge
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
await expect(card.locator('.badge-rule-override')).toBeVisible();
});
test('no badge on homework created without override', async ({ page }) => {
// 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);
await navigateToHomework(page);
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 });
const farDate = getNextWeekday(15);
await page.locator('#hw-class').selectOption({ index: 1 });
await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill('Devoir normal');
await page.locator('#hw-due-date').fill(farDate);
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByText('Devoir normal')).toBeVisible({ timeout: 10000 });
const card = page.locator('.homework-card', { hasText: 'Devoir normal' });
await expect(card.locator('.badge-rule-override')).not.toBeVisible();
});
});
});