Les tests E2E échouaient pour trois raisons principales : 1. Initialisation asynchrone TipTap — L'éditeur rich-text s'initialise via des imports dynamiques dans onMount(). Les tests interagissaient avec .rich-text-content avant que l'élément n'existe dans le DOM. Ajout d'attentes explicites avant chaque interaction avec l'éditeur. 2. Pollution inter-tests — Les fonctions de nettoyage (classes, subjects) ne supprimaient pas les tables dépendantes (homework, evaluations, schedule_slots), provoquant des erreurs FK silencieuses dans les try/catch. De plus, homework_submissions n'a pas de ON DELETE CASCADE sur homework_id, nécessitant une suppression explicite. 3. État partagé du tenant — Les règles de devoirs (homework_rules) et le calendrier scolaire (school_calendar_entries avec Vacances de Printemps) persistaient entre les fichiers de test, bloquant la création de devoirs dans des tests non liés aux règles.
497 lines
19 KiB
TypeScript
497 lines
19 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 homework-related data (deepest dependencies first)
|
|
`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}')`,
|
|
`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
|
|
`DELETE FROM homework_rule_exceptions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
|
|
`DELETE FROM homework_attachments WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}')`,
|
|
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}'`,
|
|
// Delete evaluations
|
|
`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}'`,
|
|
// Delete schedule slots (CASCADE on FK, but be explicit)
|
|
`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}'`,
|
|
// Delete assignments and classes
|
|
`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);
|
|
});
|
|
});
|
|
});
|