Files
Classeo/frontend/e2e/calendar.spec.ts
Mathias STRASSER e06fd5424d feat: Configurer les jours fériés et vacances du calendrier scolaire
Les administrateurs d'établissement avaient besoin de gérer le calendrier
scolaire (FR80) pour que l'EDT et les devoirs respectent automatiquement
les jours non travaillés. Sans cette configuration centralisée, chaque
module devait gérer indépendamment les contraintes de dates.

Le calendrier s'appuie sur l'API data.education.gouv.fr pour importer
les vacances officielles par zone (A/B/C) et calcule les 11 jours fériés
français (dont les fêtes mobiles liées à Pâques). Les enseignants sont
notifiés par email lors de l'ajout d'une journée pédagogique. Un query
IsSchoolDay et une validation des dates d'échéance de devoirs permettent
aux autres modules de s'intégrer sans couplage direct.
2026-02-18 12:09:19 +01:00

505 lines
18 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 ADMIN_EMAIL = 'e2e-calendar-admin@example.com';
const ADMIN_PASSWORD = 'CalendarTest123';
const TEACHER_EMAIL = 'e2e-calendar-teacher@example.com';
const TEACHER_PASSWORD = 'CalendarTeacher123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
// Dynamic future weekday for pedagogical day (avoids stale hardcoded dates)
const PED_DAY_DATE = (() => {
const d = new Date();
d.setMonth(d.getMonth() + 2);
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1);
return d.toISOString().split('T')[0];
})();
const PED_DAY_LABEL = 'Formation enseignants';
// Serial: empty state must be verified before import, display after import
test.describe.configure({ mode: 'serial' });
test.describe('Calendar Management (Story 2.11)', () => {
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
// Create admin and teacher test users
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 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' }
);
// Clean calendar entries to ensure empty state
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Table might not have data yet
}
});
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
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()
]);
}
// ============================================================================
// Navigation (AC1)
// ============================================================================
test.describe('Navigation', () => {
test('[P1] can access calendar page from admin navigation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.getByRole('link', { name: /calendrier/i }).click();
await expect(page).toHaveURL(/\/admin\/calendar/);
await expect(
page.getByRole('heading', { name: /calendrier scolaire/i })
).toBeVisible();
});
test('[P1] can access calendar page directly', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await expect(page).toHaveURL(/\/admin\/calendar/);
await expect(
page.getByRole('heading', { name: /calendrier scolaire/i })
).toBeVisible();
});
});
// ============================================================================
// Authorization (AC1)
// ============================================================================
test.describe('Authorization', () => {
test('[P0] teacher is redirected away from calendar admin', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Admin layout redirects non-admin roles to /dashboard
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
});
});
// ============================================================================
// Empty State (AC1)
// ============================================================================
test.describe('Empty State', () => {
test('[P1] shows empty state when no calendar configured', async ({ page }) => {
// Clean up right before test to avoid race conditions
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Ignore cleanup errors
}
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await expect(
page.getByRole('heading', { name: /calendrier scolaire/i })
).toBeVisible();
await expect(page.getByText(/aucun calendrier configuré/i)).toBeVisible({
timeout: 10000
});
await expect(
page.getByRole('button', { name: /importer le calendrier officiel/i })
).toBeVisible();
});
test('[P1] displays three year selector tabs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
const tabs = page.locator('.year-tab');
await expect(tabs).toHaveCount(3);
});
});
// ============================================================================
// Import Official Calendar (AC2)
// ============================================================================
test.describe('Import Official Calendar', () => {
test('[P1] import button opens modal with zone selector', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText(/importer le calendrier officiel/i)).toBeVisible();
});
test('[P2] import modal shows zones A, B, C', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(dialog.getByText('Zone A')).toBeVisible();
await expect(dialog.getByText('Zone B')).toBeVisible();
await expect(dialog.getByText('Zone C')).toBeVisible();
});
test('[P2] can cancel import modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('[P2] modal closes on Escape key', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Press Escape to dismiss the modal
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('[P0] shows error when import API fails', async ({ page }) => {
await loginAsAdmin(page);
// Intercept import POST to simulate server error
await page.route('**/calendar/import-official', (route) =>
route.fulfill({
status: 500,
contentType: 'application/ld+json',
body: JSON.stringify({ 'hydra:description': 'Erreur serveur interne' })
})
);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await page.getByRole('button', { name: /importer calendrier officiel/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Submit import (intercepted → 500)
await dialog.getByRole('button', { name: /^importer$/i }).click();
// Close modal to reveal error message on the page
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
// Error alert should be visible
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 5000 });
});
test('[P0] importing zone A populates calendar with entries', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Wait for empty state to be displayed
await expect(page.getByText(/aucun calendrier configuré/i)).toBeVisible({
timeout: 10000
});
// Open import modal from empty state CTA
await page.getByRole('button', { name: /importer le calendrier officiel/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Zone A is pre-selected, click import
await dialog.getByRole('button', { name: /^importer$/i }).click();
// Modal should close
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Success message
await expect(page.getByText(/calendrier officiel importé/i)).toBeVisible({
timeout: 10000
});
});
});
// ============================================================================
// Calendar Display after Import (AC1, AC3, AC4)
// ============================================================================
test.describe('Calendar Display', () => {
test('[P1] shows zone badge after import', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await expect(page.locator('.zone-badge')).toContainText('Zone A', { timeout: 10000 });
});
test('[P0] shows holidays section with actual entries', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Verify the section heading exists with a count > 0
await expect(
page.getByRole('heading', { name: /jours fériés/i })
).toBeVisible({ timeout: 10000 });
// Verify specific imported holiday entries are displayed
await expect(page.getByText('Toussaint', { exact: true })).toBeVisible();
await expect(page.getByText('Noël', { exact: true })).toBeVisible();
// Verify entry cards exist (not just the heading)
const holidaySection = page.locator('.entry-section').filter({
has: page.getByRole('heading', { name: /jours fériés/i })
});
await expect(holidaySection.locator('.entry-card').first()).toBeVisible();
});
test('[P1] shows vacations section with specific entries', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await expect(
page.getByRole('heading', { name: /vacances scolaires/i })
).toBeVisible({ timeout: 10000 });
// Verify specific vacation entry names from Zone A official data
await expect(page.getByText('Hiver')).toBeVisible();
await expect(page.getByText('Printemps')).toBeVisible();
// Verify entry cards exist within the vacation section
const vacationSection = page.locator('.entry-section').filter({
has: page.getByRole('heading', { name: /vacances scolaires/i })
});
await expect(vacationSection.locator('.entry-card').first()).toBeVisible();
const cardCount = await vacationSection.locator('.entry-card').count();
expect(cardCount).toBeGreaterThanOrEqual(4);
});
test('[P2] shows legend with color indicators', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Wait for calendar to load
await expect(
page.getByRole('heading', { name: /jours fériés/i })
).toBeVisible({ timeout: 10000 });
await expect(
page.locator('.legend-item').filter({ hasText: /jours fériés/i })
).toBeVisible();
await expect(
page.locator('.legend-item').filter({ hasText: /vacances/i })
).toBeVisible();
await expect(
page.locator('.legend-item').filter({ hasText: /journées pédagogiques/i })
).toBeVisible();
});
test('[P2] can switch between year tabs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Wait for calendar to load
await expect(
page.getByRole('heading', { name: /jours fériés/i })
).toBeVisible({ timeout: 10000 });
const tabs = page.locator('.year-tab');
// Middle tab (current year) should be active by default
await expect(tabs.nth(1)).toHaveClass(/year-tab-active/);
// Click next year tab
await tabs.nth(2).click();
await expect(tabs.nth(2)).toHaveClass(/year-tab-active/, { timeout: 5000 });
// Click previous year tab
await tabs.nth(0).click();
await expect(tabs.nth(0)).toHaveClass(/year-tab-active/, { timeout: 5000 });
});
});
// ============================================================================
// Pedagogical Day (AC5)
// ============================================================================
test.describe('Pedagogical Day', () => {
test('[P1] add pedagogical day button is visible', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await expect(
page.getByRole('button', { name: /ajouter journée pédagogique/i })
).toBeVisible();
});
test('[P1] pedagogical day modal opens with form fields', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Form fields
await expect(dialog.locator('#ped-date')).toBeVisible();
await expect(dialog.locator('#ped-label')).toBeVisible();
await expect(dialog.locator('#ped-description')).toBeVisible();
});
test('[P2] can cancel pedagogical day modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('[P1] submit button disabled when fields empty', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
const submitButton = dialog.getByRole('button', { name: /^ajouter$/i });
// Both fields empty → button disabled
await expect(submitButton).toBeDisabled();
// Fill only date → still disabled (label missing)
await dialog.locator('#ped-date').fill(PED_DAY_DATE);
await expect(submitButton).toBeDisabled();
// Clear date, fill only label → still disabled (date missing)
await dialog.locator('#ped-date').fill('');
await dialog.locator('#ped-label').fill(PED_DAY_LABEL);
await expect(submitButton).toBeDisabled();
// Fill both date and label → button enabled
await dialog.locator('#ped-date').fill(PED_DAY_DATE);
await expect(submitButton).toBeEnabled();
});
test('[P0] can add a pedagogical day successfully', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Wait for calendar to load
await expect(
page.getByRole('heading', { name: /jours fériés/i })
).toBeVisible({ timeout: 10000 });
// Open modal
await page.getByRole('button', { name: /ajouter journée pédagogique/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Fill form with dynamic future date
await dialog.locator('#ped-date').fill(PED_DAY_DATE);
await dialog.locator('#ped-label').fill(PED_DAY_LABEL);
await dialog.locator('#ped-description').fill('Journée de formation continue');
// Submit
await dialog.getByRole('button', { name: /^ajouter$/i }).click();
// Modal should close
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Success message
await expect(
page.getByText(/journée pédagogique ajoutée/i)
).toBeVisible({ timeout: 10000 });
});
test('[P1] added pedagogical day appears in list', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Wait for calendar to load
await expect(
page.getByRole('heading', { name: /jours fériés/i })
).toBeVisible({ timeout: 10000 });
// Pedagogical day section should exist with the added day
await expect(
page.getByRole('heading', { name: /journées pédagogiques/i })
).toBeVisible();
await expect(page.getByText(PED_DAY_LABEL)).toBeVisible();
});
test('[P2] pedagogical day shows distinct amber styling', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Wait for calendar to load
await expect(
page.getByRole('heading', { name: /journées pédagogiques/i })
).toBeVisible({ timeout: 10000 });
// Verify the pedagogical day section has an amber indicator dot
const pedSection = page.locator('.entry-section').filter({
has: page.getByRole('heading', { name: /journées pédagogiques/i })
});
const sectionDot = pedSection.locator('.section-dot');
await expect(sectionDot).toBeVisible();
await expect(sectionDot).toHaveCSS('background-color', 'rgb(245, 158, 11)');
});
});
});