Files
Classeo/frontend/e2e/classes.spec.ts
Mathias STRASSER f19d0ae3ef feat: Gestion des périodes scolaires
L'administration d'un établissement nécessite de découper l'année
scolaire en trimestres ou semestres avant de pouvoir saisir les notes
et générer les bulletins.

Ce module permet de configurer les périodes par année scolaire
(current/previous/next résolus en UUID v5 déterministes), de modifier
les dates individuelles avec validation anti-chevauchement, et de
consulter la période en cours avec le décompte des jours restants.

Les dates par défaut de février s'adaptent aux années bissextiles.
Le repository utilise UPSERT transactionnel pour garantir l'intégrité
lors du changement de mode (trimestres ↔ semestres). Les domain events
de Subject sont étendus pour couvrir toutes les mutations (code,
couleur, description) en plus du renommage.
2026-02-06 14:27:55 +01:00

466 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);
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
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}`;
// Test credentials
const ADMIN_EMAIL = 'e2e-classes-admin@example.com';
const ADMIN_PASSWORD = 'ClassesTest123';
// Force serial execution to ensure Empty State runs first
test.describe.configure({ mode: 'serial' });
test.describe('Classes Management (Story 2.1)', () => {
// Create admin user and clean up classes before running tests
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
// Create admin user
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' }
);
console.log('Classes E2E test admin user created');
// Clean up all classes for this tenant to ensure Empty State test works
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
console.log('Classes cleaned up for E2E tests');
} catch (error) {
console.error('Setup error:', error);
}
});
// Helper to login as admin
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 });
}
// Helper to open "Nouvelle classe" dialog with proper wait
async function openNewClassDialog(page: import('@playwright/test').Page) {
const button = page.getByRole('button', { name: /nouvelle classe/i });
await button.waitFor({ state: 'visible' });
// Wait for any pending network requests to finish before clicking
await page.waitForLoadState('networkidle');
// Click the button
await button.click();
// Wait for dialog to appear - retry click if needed (webkit timing issue)
const dialog = page.getByRole('dialog');
try {
await expect(dialog).toBeVisible({ timeout: 5000 });
} catch {
// Retry once - webkit sometimes needs a second click
await button.click();
await expect(dialog).toBeVisible({ timeout: 10000 });
}
}
// ============================================================================
// EMPTY STATE - Must run FIRST before any class is created
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state message when no classes exist', async ({ page }) => {
// Clean up classes right before this specific test to avoid race conditions with parallel browsers
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_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Ignore cleanup errors
}
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Wait for page to load
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
// Should show empty state
await expect(page.locator('.empty-state')).toBeVisible();
await expect(page.getByText(/aucune classe/i)).toBeVisible();
await expect(page.getByRole('button', { name: /créer une classe/i })).toBeVisible();
});
});
// ============================================================================
// List Display
// ============================================================================
test.describe('List Display', () => {
test('displays all created classes in the list', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Create multiple classes
const classNames = [
`Liste-6emeA-${Date.now()}`,
`Liste-6emeB-${Date.now()}`,
`Liste-5emeA-${Date.now()}`,
];
for (const name of classNames) {
await openNewClassDialog(page);
await page.locator('#class-name').fill(name);
await page.getByRole('button', { name: /créer la classe/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
}
// Verify ALL classes appear in the list
for (const name of classNames) {
await expect(page.getByText(name)).toBeVisible();
}
// Verify the number of class cards matches (at least the ones we created)
const classCards = page.locator('.class-card');
const count = await classCards.count();
expect(count).toBeGreaterThanOrEqual(classNames.length);
});
test('displays class details correctly (level, capacity)', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Create a class with all details
const className = `Details-${Date.now()}`;
await openNewClassDialog(page);
await page.locator('#class-name').fill(className);
await page.locator('#class-level').selectOption('CM2');
await page.locator('#class-capacity').fill('25');
await page.getByRole('button', { name: /créer la classe/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Find the class card
const classCard = page.locator('.class-card', { hasText: className });
await expect(classCard).toBeVisible();
// Verify details are displayed
await expect(classCard.getByText('CM2')).toBeVisible();
await expect(classCard.getByText('25 places')).toBeVisible();
await expect(classCard.getByText('Active')).toBeVisible();
});
});
// ============================================================================
// AC1: Class Creation
// ============================================================================
test.describe('AC1: Class Creation', () => {
test('can create a new class with all fields', async ({ page }) => {
await loginAsAdmin(page);
// Navigate to classes page
await page.goto(`${ALPHA_URL}/admin/classes`);
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
// Click "Nouvelle classe" button
await openNewClassDialog(page);
await expect(page.getByRole('heading', { name: /nouvelle classe/i })).toBeVisible();
// Fill form
const uniqueName = `Test-E2E-${Date.now()}`;
await page.locator('#class-name').fill(uniqueName);
await page.locator('#class-level').selectOption('6ème');
await page.locator('#class-capacity').fill('30');
// Submit
await page.getByRole('button', { name: /créer la classe/i }).click();
// Modal should close and class should appear in list
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
});
test('can create a class with only required fields (name)', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await openNewClassDialog(page);
// Fill only the name (required)
const uniqueName = `Minimal-${Date.now()}`;
await page.locator('#class-name').fill(uniqueName);
// Submit button should be enabled
const submitButton = page.getByRole('button', { name: /créer la classe/i });
await expect(submitButton).toBeEnabled();
await submitButton.click();
// Class should be created
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
});
test('submit button is disabled when name is empty', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await openNewClassDialog(page);
// Don't fill the name
const submitButton = page.getByRole('button', { name: /créer la classe/i });
await expect(submitButton).toBeDisabled();
// Fill level and capacity but not name
await page.locator('#class-level').selectOption('CE1');
await page.locator('#class-capacity').fill('25');
await expect(submitButton).toBeDisabled();
// Fill name - button should enable
await page.locator('#class-name').fill('Test');
await expect(submitButton).toBeEnabled();
});
test('can cancel class creation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await openNewClassDialog(page);
// Fill form
await page.locator('#class-name').fill('Should-Not-Be-Created');
// Click cancel
await page.getByRole('button', { name: /annuler/i }).click();
// Modal should close
await expect(page.getByRole('dialog')).not.toBeVisible();
// Class should not appear in list
await expect(page.getByText('Should-Not-Be-Created')).not.toBeVisible();
});
});
// ============================================================================
// AC2: Class Modification
// ============================================================================
test.describe('AC2: Class Modification', () => {
test('can modify an existing class', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// First create a class to modify
await openNewClassDialog(page);
const originalName = `ToModify-${Date.now()}`;
await page.locator('#class-name').fill(originalName);
await page.locator('#class-level').selectOption('CM1');
await page.getByRole('button', { name: /créer la classe/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Find the class card and click modify
const classCard = page.locator('.class-card', { hasText: originalName });
await classCard.getByRole('button', { name: /modifier/i }).click();
// Should navigate to edit page
await expect(page).toHaveURL(/\/admin\/classes\/[\w-]+/);
await expect(page.getByRole('heading', { name: /modifier la classe/i })).toBeVisible();
// Modify the name
const newName = `Modified-${Date.now()}`;
await page.locator('#class-name').fill(newName);
await page.locator('#class-level').selectOption('CM2');
await page.locator('#class-capacity').fill('28');
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
// Should show success message
await expect(page.getByText(/modifiée avec succès/i)).toBeVisible({ timeout: 10000 });
// Go back to list and verify
await page.goto(`${ALPHA_URL}/admin/classes`);
await expect(page.getByText(newName)).toBeVisible();
});
test('can cancel modification', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Create a class
await openNewClassDialog(page);
const originalName = `NoChange-${Date.now()}`;
await page.locator('#class-name').fill(originalName);
await page.getByRole('button', { name: /créer la classe/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Click modify
const classCard = page.locator('.class-card', { hasText: originalName });
await classCard.getByRole('button', { name: /modifier/i }).click();
// Modify but cancel
await page.locator('#class-name').fill('Should-Not-Change');
await page.getByRole('button', { name: /annuler/i }).click();
// Should go back to list
await expect(page).toHaveURL(/\/admin\/classes$/);
// Original name should still be there
await expect(page.getByText(originalName)).toBeVisible();
await expect(page.getByText('Should-Not-Change')).not.toBeVisible();
});
});
// ============================================================================
// AC3: Deletion blocked if students assigned
// ============================================================================
test.describe('AC3: Deletion blocked if students assigned', () => {
// SKIP REASON: The Students module is not yet implemented.
// HasStudentsInClassHandler currently returns 0 (stub), so all classes
// appear empty and can be deleted. This test will be enabled once the
// Students module allows assigning students to classes.
//
// When enabled, this test should:
// 1. Create a class
// 2. Assign at least one student to it
// 3. Attempt to delete the class
// 4. Verify the error message "Vous devez d'abord réaffecter les élèves"
// 5. Verify the class still exists
test.skip('shows warning when trying to delete class with students', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Implementation pending Students module
});
});
// ============================================================================
// AC4: Empty class deletion (soft delete)
// ============================================================================
test.describe('AC4: Empty class deletion (soft delete)', () => {
test('can delete an empty class', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Create a class to delete
await openNewClassDialog(page);
const className = `ToDelete-${Date.now()}`;
await page.locator('#class-name').fill(className);
await page.getByRole('button', { name: /créer la classe/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(className)).toBeVisible();
// Find and click delete button
const classCard = page.locator('.class-card', { hasText: className });
await classCard.getByRole('button', { name: /supprimer/i }).click();
// Confirmation modal should appear
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
await expect(deleteModal.getByText(className)).toBeVisible();
// Confirm deletion
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
// Modal should close and class should no longer appear in list
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(className)).not.toBeVisible({ timeout: 10000 });
});
test('can cancel deletion', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Create a class
await openNewClassDialog(page);
const className = `NoDelete-${Date.now()}`;
await page.locator('#class-name').fill(className);
await page.getByRole('button', { name: /créer la classe/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Find and click delete
const classCard = page.locator('.class-card', { hasText: className });
await classCard.getByRole('button', { name: /supprimer/i }).click();
// Confirmation modal should appear
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
// Cancel deletion
await deleteModal.getByRole('button', { name: /annuler/i }).click();
// Modal should close and class should still be there
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(className)).toBeVisible();
});
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('can access classes page directly', async ({ page }) => {
await loginAsAdmin(page);
// Navigate directly to classes page
await page.goto(`${ALPHA_URL}/admin/classes`);
await expect(page).toHaveURL(/\/admin\/classes/);
await expect(page.getByRole('heading', { name: /gestion des classes/i })).toBeVisible();
});
test('breadcrumb navigation works on edit page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
// Create a class
await openNewClassDialog(page);
const className = `Breadcrumb-${Date.now()}`;
await page.locator('#class-name').fill(className);
await page.getByRole('button', { name: /créer la classe/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Go to edit page
const classCard = page.locator('.class-card', { hasText: className });
await classCard.getByRole('button', { name: /modifier/i }).click();
// Click breadcrumb to go back (scoped to main to avoid matching nav link)
await page.getByRole('main').getByRole('link', { name: 'Classes' }).click();
await expect(page).toHaveURL(/\/admin\/classes$/);
});
});
// ============================================================================
// Validation
// ============================================================================
test.describe('Validation', () => {
test('shows validation for class name length', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await openNewClassDialog(page);
// Try a name that's too short (1 char)
await page.locator('#class-name').fill('A');
// The HTML5 minlength validation should prevent submission
// or show an error
const nameInput = page.locator('#class-name');
const isInvalid = await nameInput.evaluate(
(el: HTMLInputElement) => !el.validity.valid
);
expect(isInvalid).toBe(true);
});
});
});