Files
Classeo/frontend/e2e/pedagogy.spec.ts
Mathias STRASSER e930c505df feat: Attribution de rôles multiples par utilisateur
Les utilisateurs Classeo étaient limités à un seul rôle, alors que
dans la réalité scolaire un directeur peut aussi être enseignant,
ou un parent peut avoir un rôle vie scolaire. Cette limitation
obligeait à créer des comptes distincts par fonction.

Le modèle User supporte désormais plusieurs rôles simultanés avec
basculement via le header. L'admin peut attribuer/retirer des rôles
depuis l'interface de gestion, avec des garde-fous : pas d'auto-
destitution, pas d'escalade de privilèges (seul SUPER_ADMIN peut
attribuer SUPER_ADMIN), vérification du statut actif pour le
switch de rôle, et TTL explicite sur le cache de rôle actif.
2026-02-10 11:46:55 +01:00

280 lines
10 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-pedagogy-admin@example.com';
const ADMIN_PASSWORD = 'PedagogyTest123';
test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
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' }
);
// Reset grading mode to default (numeric_20) to ensure clean state
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php -r "
require 'vendor/autoload.php';
\\$kernel = new App\\Kernel('dev', true);
\\$kernel->boot();
\\$conn = \\$kernel->getContainer()->get('doctrine')->getConnection();
\\$conn->executeStatement('DELETE FROM school_grading_configurations');
" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Table might not exist yet, that's OK — default mode applies
}
});
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 page.getByRole('button', { name: /se connecter/i }).click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
}
// =========================================================================
// Navigation
// =========================================================================
test('pedagogy link is visible in admin navigation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
const pedagogyLink = page.getByRole('link', { name: /pédagogie/i });
await expect(pedagogyLink).toBeVisible();
});
test('pedagogy link navigates to pedagogy page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.getByRole('link', { name: /pédagogie/i }).click();
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
});
test('pedagogy card is visible on admin dashboard', async ({ page }) => {
await loginAsAdmin(page);
// Authenticated admin sees admin dashboard directly via role context
await page.goto(`${ALPHA_URL}/dashboard`);
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({
timeout: 10000
});
const pedagogyCard = page.getByRole('link', { name: /pédagogie/i });
await expect(pedagogyCard).toBeVisible();
});
// =========================================================================
// AC1: Display grading mode options
// =========================================================================
test('shows page title and subtitle', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await expect(page.getByRole('heading', { name: /mode de notation/i })).toBeVisible();
await expect(page.getByText(/système d'évaluation/i)).toBeVisible();
});
test('shows current mode banner', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
// Wait for loading to finish
await page.waitForLoadState('networkidle');
// Should show "Mode actuel" banner
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
});
test('displays all five grading modes', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// All 5 mode cards should be visible (scope to .mode-card to avoid ambiguous matches)
const modeCards = page.locator('.mode-card');
await expect(modeCards).toHaveCount(5, { timeout: 10000 });
await expect(modeCards.filter({ hasText: /notes \/20/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /notes \/10/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /lettres/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /compétences/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /sans notes/i })).toBeVisible();
});
test('default mode is Notes sur 20', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// The "Notes /20" mode card should be selected (has mode-selected class)
const selectedCard = page.locator('.mode-card.mode-selected');
await expect(selectedCard).toBeVisible({ timeout: 10000 });
await expect(selectedCard).toContainText(/notes \/20/i);
});
// =========================================================================
// AC1: Year selector
// =========================================================================
test('can switch between year tabs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
const tabs = page.getByRole('tab');
// Wait for page to load
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
await page.waitForLoadState('networkidle');
// Click next year tab
await tabs.nth(2).click();
await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
// Click previous year tab
await tabs.nth(0).click();
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
});
// =========================================================================
// AC2-4: Mode selection and preview
// =========================================================================
test('selecting a different mode shows save button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Save button should not be visible initially
await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible();
// Click a different mode
const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i });
await competenciesCard.click();
// Save and cancel buttons should appear
await expect(page.getByRole('button', { name: /enregistrer/i })).toBeVisible();
await expect(page.getByRole('button', { name: /annuler/i })).toBeVisible();
});
test('cancel button reverts mode selection', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Select a different mode
const lettersCard = page.locator('.mode-card').filter({ hasText: /lettres/i });
await lettersCard.click();
// Click cancel
await page.getByRole('button', { name: /annuler/i }).click();
// Save button should disappear
await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible();
});
test('selecting competencies mode shows competency-specific description', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i });
await competenciesCard.click();
// Check description inside the card (scoped to avoid matching bulletin impact text)
await expect(competenciesCard.locator('.mode-description')).toContainText(
/acquis.*en cours.*non acquis/i
);
});
test('selecting sans notes mode shows no-grades description', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
const noGradesCard = page.locator('.mode-card').filter({ hasText: /sans notes/i });
await noGradesCard.click();
// Check description inside the card (scoped to avoid matching bulletin impact text)
await expect(noGradesCard.locator('.mode-description')).toContainText(
/appréciations textuelles/i
);
});
test('shows bulletin impact preview when mode is selected', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Impact section should be visible
await expect(page.getByText(/impact sur les bulletins/i)).toBeVisible({ timeout: 10000 });
});
// =========================================================================
// AC2: Can change to a different mode
// =========================================================================
test('can save a new grading mode', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
// Select "Notes sur 10"
const numeric10Card = page.locator('.mode-card').filter({ hasText: /notes \/10/i });
await numeric10Card.click();
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
// Success message should appear
await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 });
// The selected mode should now be "Notes /10"
const selectedCard = page.locator('.mode-card.mode-selected');
await expect(selectedCard).toContainText(/notes \/10/i);
// Reload the page to verify server-side persistence
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator('.mode-card.mode-selected')).toContainText(/notes \/10/i, {
timeout: 10000
});
// Restore default mode for other tests
const numeric20Card = page.locator('.mode-card').filter({ hasText: /notes \/20/i });
await numeric20Card.click();
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 });
});
});