Files
Classeo/frontend/e2e/parent-schedule.spec.ts
Mathias STRASSER aedde6707e
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-03-31 16:43:10 +02:00

565 lines
22 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 PARENT_EMAIL = 'e2e-parent-schedule@example.com';
const PARENT_PASSWORD = 'ParentSchedule123';
const STUDENT_EMAIL = 'e2e-parent-sched-student@example.com';
const STUDENT_PASSWORD = 'StudentParentSched123';
const STUDENT2_EMAIL = 'e2e-parent-sched-student2@example.com';
const STUDENT2_PASSWORD = 'StudentParentSched2_123';
const TEACHER_EMAIL = 'e2e-parent-sched-teacher@example.com';
const TEACHER_PASSWORD = 'TeacherParentSched123';
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 currentWeekdayIso(): number {
const jsDay = new Date().getDay();
if (jsDay === 0) return 5; // Sunday → seed for Friday
if (jsDay === 6) return 5; // Saturday → seed for Friday
return jsDay;
}
function daysBackToSeededWeekday(): number {
const jsDay = new Date().getDay();
if (jsDay === 6) return 1; // Saturday → go back 1 day to Friday
if (jsDay === 0) return 2; // Sunday → go back 2 days to Friday
return 0;
}
function seededDayName(): string {
const jsDay = new Date().getDay();
const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay;
return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!;
}
async function navigateToSeededDay(page: import('@playwright/test').Page) {
const back = daysBackToSeededWeekday();
if (back === 0) return;
const targetDay = seededDayName();
const targetPattern = new RegExp(targetDay, 'i');
const prevBtn = page.getByLabel('Précédent');
await expect(prevBtn).toBeVisible({ timeout: 10000 });
const deadline = Date.now() + 15000;
let navigated = false;
while (Date.now() < deadline && !navigated) {
for (let i = 0; i < back; i++) {
await prevBtn.click();
}
await page.waitForTimeout(500);
const title = await page.locator('.day-title').textContent();
if (title && targetPattern.test(title)) {
navigated = true;
}
}
await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({
timeout: 5000
});
}
async function loginAsParent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
/**
* Multi-child parents land on the summary view (no child auto-selected).
* This helper selects the first actual child (skipping the "Tous" button).
*/
async function selectFirstChild(page: import('@playwright/test').Page) {
const childButtons = page.locator('.child-button');
await expect(childButtons.first()).toBeVisible({ timeout: 15000 });
const count = await childButtons.count();
if (count > 2) {
// Multi-child: "Tous" is at index 0, first child at index 1
await childButtons.nth(1).click();
}
// Single child is auto-selected, nothing to do
}
test.describe('Parent Schedule Consultation (Story 4.4)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Clear caches to prevent stale data from previous runs
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache student_guardians.cache --env=dev 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pools may not exist
}
// Create users (idempotent - returns existing if already created)
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
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();
// Create classes
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-ParentSched-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-ParentSched-5B', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Create subjects
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-ParentSched-Maths', 'E2EPARMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-ParentSched-SVT', 'E2EPARSVT', '#22c55e', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Clean up schedule data
try {
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name LIKE 'E2E-ParentSched-%' AND tenant_id = '${TENANT_ID}')`);
} catch {
// Table may not exist
}
// Clean up calendar entries
try {
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
// Assign students to classes
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
`FROM users u, school_classes c ` +
`WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
`FROM users u, school_classes c ` +
`WHERE u.email = '${STUDENT2_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Clean up any existing guardian links for our parent
try {
runSql(
`DELETE FROM student_guardians WHERE guardian_id IN (SELECT id FROM users WHERE email = '${PARENT_EMAIL}' AND tenant_id = '${TENANT_ID}') AND tenant_id = '${TENANT_ID}'`
);
} catch {
// Table may not exist
}
// Create parent-student links
runSql(
`INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` +
`SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` +
`FROM users s, users p ` +
`WHERE s.email = '${STUDENT_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` +
`AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING`
);
runSql(
`INSERT INTO student_guardians (id, student_id, guardian_id, relationship_type, tenant_id, created_at) ` +
`SELECT gen_random_uuid(), s.id, p.id, 'père', '${TENANT_ID}', NOW() ` +
`FROM users s, users p ` +
`WHERE s.email = '${STUDENT2_EMAIL}' AND s.tenant_id = '${TENANT_ID}' ` +
`AND p.email = '${PARENT_EMAIL}' AND p.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT (student_id, guardian_id, tenant_id) DO NOTHING`
);
// Create schedule slots
const dayOfWeek = currentWeekdayIso();
runSql(
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '08:00', '09:00', 'Salle A1', true, NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
`WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
);
runSql(
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '10:00', '11:00', 'Labo SVT', true, NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2EPARSVT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
`WHERE c.name = 'E2E-ParentSched-5B' AND c.tenant_id = '${TENANT_ID}'`
);
// Late-night slot for child 1 (6A) — always "next" during normal test hours
runSql(
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '23:00', '23:30', 'Salle A1', true, NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2EPARMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
`WHERE c.name = 'E2E-ParentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
);
clearCache();
});
// ======================================================================
// AC1: Single child view
// ======================================================================
test.describe('AC1: Single child day view', () => {
test('parent can navigate to parent-schedule page', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
});
test('child selector shows children', async ({ page }) => {
await loginAsParent(page);
// Intercept the API call to debug
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/me/children') && !resp.url().includes('schedule'),
{ timeout: 30000 }
);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
const response = await responsePromise;
expect(response.status()).toBe(200);
// Wait for child selector to finish loading
const childSelector = page.locator('.child-selector');
await expect(childSelector).toBeVisible({ timeout: 15000 });
});
test('day view shows schedule for selected child', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
await navigateToSeededDay(page);
// Wait for slots to load
const slots = page.locator('[data-testid="schedule-slot"]');
await expect(slots.first()).toBeVisible({ timeout: 20000 });
});
});
// ======================================================================
// AC2: Multi-child view
// ======================================================================
test.describe('AC2: Multi-child selection', () => {
test('can switch between children', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
// Multi-child: "Tous" + N children buttons
const childButtons = page.locator('.child-button');
const count = await childButtons.count();
if (count > 2) {
// Select first child (index 1, after "Tous")
await childButtons.nth(1).click();
await navigateToSeededDay(page);
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
// Switch to second child (index 2)
await childButtons.nth(2).click();
await page.waitForTimeout(500);
await navigateToSeededDay(page);
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
// Switch back to "Tous" summary view
await childButtons.nth(0).click();
await expect(page.locator('.multi-child-summary')).toBeVisible({ timeout: 10000 });
}
});
});
// ======================================================================
// AC3: Navigation (day/week views)
// ======================================================================
test.describe('AC3: Navigation', () => {
test('day view is the default view', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
});
test('can switch to week view', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
await navigateToSeededDay(page);
// Wait for slots to load
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
// Switch to week view
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
await weekButton.click();
// Week headers should show
await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
});
test('can navigate between days', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
await navigateToSeededDay(page);
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
// Navigate forward and wait for the new day to load
await page.getByLabel('Suivant').click();
// Wait for the day title to change, confirming navigation completed
await page.waitForTimeout(1500);
// Navigate back to the original day
await page.getByLabel('Précédent').click();
// Wait for data to reload after navigation
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 30000
});
});
});
// ======================================================================
// AC1: Next class highlighting (P0)
// ======================================================================
test.describe('AC1: Next class highlighting', () => {
test('next class is highlighted with badge on today view', async ({ page }) => {
// Next class highlighting only works when viewing today's date
const jsDay = new Date().getDay();
test.skip(jsDay === 0 || jsDay === 6, 'Next class highlighting only works on weekdays');
// The seeded slot is at 23:00 — if the test runs after 22:30 the slot
// may be current/past and won't have the "next" class.
const hour = new Date().getHours();
test.skip(hour >= 23, 'Seeded 23:00 slot is past/current — cannot test next-class highlighting');
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
// Wait for schedule slots to load
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
// The 23:00 slot should always be "next" during normal test hours
const nextSlot = page.locator('.slot-item.next');
await expect(nextSlot).toBeVisible({ timeout: 10000 });
// Verify the "Prochain" badge is displayed
await expect(nextSlot.locator('.next-badge')).toBeVisible();
await expect(nextSlot.locator('.next-badge')).toHaveText('Prochain');
});
});
// ======================================================================
// AC2: Multi-child content verification (P1)
// ======================================================================
test.describe('AC2: Multi-child schedule content', () => {
test('switching children shows different subjects', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
const childButtons = page.locator('.child-button');
const count = await childButtons.count();
// "Tous" + at least 2 children = 3 buttons minimum
test.skip(count < 3, 'Need at least 2 children for this test');
// Select first child (index 1, after "Tous")
await childButtons.nth(1).click();
await navigateToSeededDay(page);
// First child (6A) should show Maths
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
await expect(page.getByText('E2E-ParentSched-Maths').first()).toBeVisible({
timeout: 5000
});
// Switch to second child (index 2) (5B) — should show SVT
await childButtons.nth(2).click();
await navigateToSeededDay(page);
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
await expect(page.getByText('E2E-ParentSched-SVT').first()).toBeVisible({
timeout: 5000
});
});
});
// ======================================================================
// AC5: Offline mode
// ======================================================================
test.describe('AC5: Offline mode', () => {
test('shows offline banner when network is lost', async ({ page, context }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await selectFirstChild(page);
await navigateToSeededDay(page);
// Wait for schedule to load
await expect(
page.locator('[data-testid="schedule-slot"]').first()
).toBeVisible({ timeout: 20000 });
// Go offline
await context.setOffline(true);
const offlineBanner = page.locator('.offline-banner[role="status"]');
await expect(offlineBanner).toBeVisible({ timeout: 5000 });
await expect(offlineBanner.getByText('Hors ligne')).toBeVisible();
// Restore online
await context.setOffline(false);
await expect(offlineBanner).not.toBeVisible({ timeout: 5000 });
});
});
// ======================================================================
// Navigation link
// ======================================================================
test.describe('Navigation link', () => {
test('EDT enfants link is visible for parent role', async ({ page }) => {
await loginAsParent(page);
const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' });
await expect(navLink).toBeVisible({ timeout: 10000 });
});
test('clicking EDT enfants link navigates to parent-schedule page', async ({ page }) => {
await loginAsParent(page);
const navLink = page.locator('.desktop-nav a', { hasText: 'EDT enfants' });
await expect(navLink).toBeVisible({ timeout: 10000 });
await navLink.click();
await expect(page).toHaveURL(/\/dashboard\/parent-schedule/);
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
});
});
});