Le menu d'administration contenait 13 liens à plat dans le header, ce qui débordait sur desktop et rendait le drawer mobile trop long à scanner. Les liens sont maintenant regroupés en 4 catégories (Personnes, Organisation, Année scolaire, Paramètres) avec des dropdowns au survol sur desktop et des accordéons repliables dans le drawer mobile. Le nombre d'éléments visibles passe de 13 à 5 (1 lien direct + 4 catégories), la catégorie active s'auto-déplie dans le menu mobile.
509 lines
18 KiB
TypeScript
509 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`);
|
|
|
|
// 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
|
|
}
|
|
|
|
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)');
|
|
});
|
|
});
|
|
});
|