Les administrateurs et enseignants ont besoin de consulter et gérer les autorisations de droit à l'image des élèves pour respecter la réglementation lors de publications contenant des photos (FR82). Cette fonctionnalité ajoute une page dédiée avec liste filtrable par statut, modification individuelle via dropdown, export CSV avec BOM UTF-8 pour Excel, et préparation du système d'avertissement avant publication (query/handler prêts, intégration à faire quand le module publication existera). Le filtrage par classe (AC2) est bloqué en attente d'une table d'affectation élève↔classe qui n'existe pas encore.
506 lines
18 KiB
TypeScript
506 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 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)');
|
|
});
|
|
});
|
|
});
|