Files
Classeo/frontend/e2e/calendar.spec.ts
Mathias STRASSER 272d31e1c0
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: Permettre à l'élève de consulter ses notes et moyennes
L'élève avait accès à ses compétences mais pas à ses notes numériques.
Cette fonctionnalité lui donne une vue complète de sa progression scolaire
avec moyennes par matière, détail par évaluation, statistiques de classe,
et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15).

Les notes ne sont visibles qu'après publication par l'enseignant, ce qui
garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
2026-04-07 10:00:28 +02:00

562 lines
21 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, French holidays, and summer vacation)
const PED_DAY_DATE = (() => {
const d = new Date();
d.setDate(d.getDate() + 30); // ~1 month ahead, stays within school time (avoids summer vacation)
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1);
// Skip known French fixed holidays (MM-DD)
const holidays = ['01-01', '05-01', '05-08', '07-14', '08-15', '11-01', '11-11', '12-25'];
let mmdd = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
if (holidays.includes(mmdd)) d.setDate(d.getDate() + 1);
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1);
// Double-check after weekend skip
mmdd = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
if (holidays.includes(mmdd)) d.setDate(d.getDate() + 1);
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
}
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 in all environments
}
});
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: 60000 }),
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: 60000 }),
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`);
// Hover "Année scolaire" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /année scolaire/i }).hover();
await nav.getByRole('menuitem', { 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 can access admin layout but calendar returns error', async ({ page }) => {
await loginAsTeacher(page);
await page.goto(`${ALPHA_URL}/admin/calendar`);
// Teacher can access admin layout (ROLE_PROF in ADMIN_ROLES for image-rights)
// but calendar page may show access denied from backend
await expect(page).toHaveURL(/\/admin\/calendar/, { 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
}
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 in all environments
}
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({ timeout: 15000 });
await expect(page.getByText('Noël', { exact: true })).toBeVisible({ timeout: 15000 });
// 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', () => {
// Clean up any existing ped day with the same date before the serial ped day tests
test.beforeAll(async () => {
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}' AND type = 'pedagogical_day' AND start_date = '${PED_DAY_DATE}'" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Ignore cleanup errors
}
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 in all environments
}
});
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 and wait for API response
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/calendar/pedagogical-day') && resp.request().method() === 'POST'
);
await dialog.getByRole('button', { name: /^ajouter$/i }).click();
const response = await responsePromise;
expect(response.status()).toBeLessThan(400);
// Modal should close
await expect(dialog).not.toBeVisible({ timeout: 15000 });
// 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)');
});
});
});