Files
Classeo/frontend/e2e/classes.spec.ts
Mathias STRASSER 1db8a7a0b2
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
fix: Corriger les tests E2E après l'introduction du cache-aside paginé
Le commit 23dd717 a introduit un cache Redis (paginated_queries.cache)
pour les requêtes paginées. Les tests E2E qui modifient les données via
SQL direct (beforeAll, cleanup) contournent la couche applicative et ne
déclenchent pas l'invalidation du cache, provoquant des données obsolètes.

De plus, plusieurs problèmes d'isolation entre tests ont été découverts :
- Les tests classes.spec.ts supprimaient les données d'autres specs via
  DELETE FROM school_classes sans nettoyer les FK dépendantes
- Les tests user-blocking utilisaient des emails partagés entre les
  projets Playwright (chromium/firefox/webkit) exécutés en parallèle,
  causant des race conditions sur l'état du compte utilisateur
- Le handler NotifyTeachersPedagogicalDayHandler s'exécutait de manière
  synchrone, bloquant la réponse HTTP pendant l'envoi des emails
- La sélection d'un enseignant remplaçant effaçait l'autre dropdown car
  {#if} supprimait l'option sélectionnée du DOM

Corrections appliquées :
- Ajout de cache:pool:clear après chaque modification SQL directe
- Nettoyage des FK dépendantes avant les DELETE (classes, subjects)
- Emails uniques par projet navigateur pour éviter les race conditions
- Routage de JourneePedagogiqueAjoutee vers le transport async
- Remplacement de {#if} par disabled sur les selects de remplacement
- Recherche par nom sur la page classes pour gérer la pagination
- Patterns toPass() pour la fiabilité Firefox sur les color pickers
2026-03-01 23:33:42 +01:00

486 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';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
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
}
}
function cleanupClasses() {
const sqls = [
`DELETE FROM replacement_classes WHERE replacement_id IN (SELECT id FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}')`,
`DELETE FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}'`,
`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`,
`DELETE FROM class_assignments WHERE tenant_id = '${TENANT_ID}'`,
`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}'`,
];
for (const sql of sqls) {
try {
runSql(sql);
} catch {
// Table may not exist yet
}
}
}
// 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 () => {
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' }
);
cleanupClasses();
clearCache();
});
// 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 Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// 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 and all dependent tables right before this test
cleanupClasses();
clearCache();
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);
});
});
});