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.
381 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|