Files
Classeo/frontend/e2e/student-creation.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

661 lines
25 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 TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const ADMIN_EMAIL = 'e2e-student-creation-admin@example.com';
const ADMIN_PASSWORD = 'StudentCreationTest123';
const UNIQUE_SUFFIX = Date.now();
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runCommand(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 resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId, academicYearId };
}
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 waitForStudentsPage(page: import('@playwright/test').Page) {
await expect(
page.getByRole('heading', { name: /gestion des élèves/i })
).toBeVisible({ timeout: 15000 });
await expect(
page.locator('.empty-state, .students-table, .alert-error')
).toBeVisible({ timeout: 15000 });
}
let testClassId: string;
let testClassId2: string;
function createBulkStudents(count: number, tenantId: string, classId: string, academicYearId: string) {
for (let i = 0; i < count; i++) {
const suffix = UNIQUE_SUFFIX.toString().slice(-8);
const paddedI = String(i).padStart(4, '0');
const userId = `00000000-e2e0-4000-8000-${suffix}${paddedI}`;
const assignmentId = `00000001-e2e0-4000-8000-${suffix}${paddedI}`;
try {
runCommand(
`INSERT INTO users (id, tenant_id, email, first_name, last_name, roles, hashed_password, statut, school_name, date_naissance, created_at, activated_at, invited_at, blocked_at, blocked_reason, consentement_parent_id, consentement_eleve_id, consentement_date, consentement_ip, image_rights_status, image_rights_updated_at, image_rights_updated_by, student_number, updated_at) VALUES ('${userId}', '${tenantId}', NULL, 'Pagination${i}', 'Student-${UNIQUE_SUFFIX}', '[\\"ROLE_ELEVE\\"]', NULL, 'inscrit', 'E2E Test School', NULL, NOW(), NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'not_specified', NULL, NULL, NULL, NOW()) ON CONFLICT (id) DO NOTHING`
);
runCommand(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) VALUES ('${assignmentId}', '${tenantId}', '${userId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
);
} catch {
// Student may already exist
}
}
}
test.describe('Student Creation & Management (Story 3.0)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// 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' }
);
// Create a test class for student assignment
const { schoolId, academicYearId } = resolveDeterministicIds();
testClassId = `e2e-class-${UNIQUE_SUFFIX}`.substring(0, 36).padEnd(36, '0');
// Use a valid UUID format
testClassId = `00000000-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`;
try {
runCommand(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class ${UNIQUE_SUFFIX}', 'CM2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
);
} catch {
// Class may already exist
}
// Create a second test class for change-class tests
testClassId2 = `00000001-0000-0000-0000-${UNIQUE_SUFFIX.toString().padStart(12, '0')}`;
try {
runCommand(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${testClassId2}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Test Class 2 ${UNIQUE_SUFFIX}', 'CE2', 30, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
);
} catch {
// Class may already exist
}
// Create 31 students for pagination tests (itemsPerPage = 30)
createBulkStudents(31, TENANT_ID, testClassId, academicYearId);
clearCache();
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('students page is accessible from admin nav', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await expect(page.getByRole('heading', { name: /gestion des élèves/i })).toBeVisible({
timeout: 10000
});
});
test('page title is set correctly', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await expect(page).toHaveTitle(/gestion des élèves/i);
});
test('nav menu shows Élèves link', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
// Hover "Personnes" category to reveal dropdown with "Élèves" link
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /personnes/i }).hover();
await expect(nav.getByRole('menuitem', { name: /élèves/i })).toBeVisible({
timeout: 10000
});
});
});
// ============================================================================
// AC1: Create Student Form
// ============================================================================
test.describe('AC1 - Create Student Modal', () => {
test('can open create student modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(
dialog.getByRole('heading', { name: /nouvel élève/i })
).toBeVisible();
});
test('modal has all required fields', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Required fields
await expect(dialog.locator('#student-lastname')).toBeVisible();
await expect(dialog.locator('#student-firstname')).toBeVisible();
await expect(dialog.locator('#student-class')).toBeVisible();
// Optional fields
await expect(dialog.locator('#student-email')).toBeVisible();
await expect(dialog.locator('#student-dob')).toBeVisible();
await expect(dialog.locator('#student-ine')).toBeVisible();
});
test('class dropdown uses optgroup by level', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Wait for classes to load in the dropdown (optgroup elements inside select are not "visible" per Playwright)
await expect(dialog.locator('#student-class optgroup')).not.toHaveCount(0, { timeout: 10000 });
// Check optgroups exist in the class select
const optgroups = dialog.locator('#student-class optgroup');
const count = await optgroups.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test('can close modal with cancel button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/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();
});
test('can close modal with Escape key', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Focus the modal so the keydown handler receives the event
await dialog.focus();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible();
});
});
// ============================================================================
// AC2: Create student with class assignment
// ============================================================================
test.describe('AC2 - Student Creation', () => {
test('can create a student without email', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Fill required fields
await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`);
await dialog.locator('#student-firstname').fill('Marie');
// Select first available class
await dialog.locator('#student-class').selectOption({ index: 1 });
// Submit
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
// Success message should appear
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/inscrit/i);
// Student should appear in the list
await expect(page.locator('.students-table')).toBeVisible({ timeout: 5000 });
await expect(
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` })
).toBeVisible();
});
test('can create a student with email', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
await dialog.locator('#student-lastname').fill(`Martin-${UNIQUE_SUFFIX}`);
await dialog.locator('#student-firstname').fill('Jean');
await dialog.locator('#student-email').fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`);
await dialog.locator('#student-class').selectOption({ index: 1 });
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/invitation/i);
});
test('"Créer un autre" keeps modal open', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Check "Créer un autre élève"
await dialog.locator('input[type="checkbox"]').check();
await dialog.locator('#student-lastname').fill(`Bernard-${UNIQUE_SUFFIX}`);
await dialog.locator('#student-firstname').fill('Luc');
await dialog.locator('#student-class').selectOption({ index: 1 });
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
// Success should appear but modal stays open
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(dialog).toBeVisible();
// Form fields should be cleared
await expect(dialog.locator('#student-lastname')).toHaveValue('');
await expect(dialog.locator('#student-firstname')).toHaveValue('');
// Close the modal
await dialog.getByRole('button', { name: /annuler/i }).click();
});
});
// ============================================================================
// AC3: Data validation
// ============================================================================
test.describe('AC3 - Validation', () => {
test('[P0] INE validation shows error for invalid format', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Fill required fields
await dialog.locator('#student-firstname').fill('Test');
await dialog.locator('#student-lastname').fill('INEValidation');
await dialog.locator('#student-class').selectOption({ index: 1 });
// Enter invalid INE (too short)
await dialog.locator('#student-ine').fill('ABC');
// Error message should appear
await expect(dialog.locator('.field-error')).toBeVisible();
await expect(dialog.locator('.field-error')).toContainText(/11 caractères/i);
// Submit button should be disabled
await expect(
dialog.getByRole('button', { name: /créer l'élève/i })
).toBeDisabled();
// Fix INE to valid format (11 alphanumeric chars)
await dialog.locator('#student-ine').fill('12345678901');
// Error should disappear
await expect(dialog.locator('.field-error')).not.toBeVisible();
// Submit button should be enabled
await expect(
dialog.getByRole('button', { name: /créer l'élève/i })
).toBeEnabled();
// Close modal without creating
await dialog.getByRole('button', { name: /annuler/i }).click();
});
test('[P0] shows error when email is already used', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Use the same email as the student created in AC2
await dialog.locator('#student-firstname').fill('Doublon');
await dialog.locator('#student-lastname').fill('Email');
await dialog
.locator('#student-email')
.fill(`jean.martin.${UNIQUE_SUFFIX}@example.com`);
await dialog.locator('#student-class').selectOption({ index: 1 });
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
// Error should appear (from API)
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-error')).toContainText(/email/i);
// Close modal
await dialog.getByRole('button', { name: /annuler/i }).click();
});
test('[P0] shows duplicate warning for same name in same class', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
await page.getByRole('button', { name: /nouvel élève/i }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
// Use same name as student created in AC2
await dialog.locator('#student-firstname').fill('Marie');
await dialog.locator('#student-lastname').fill(`Dupont-${UNIQUE_SUFFIX}`);
await dialog.locator('#student-class').selectOption({ index: 1 });
// Submit — should trigger duplicate check
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
// Duplicate warning should appear
await expect(dialog.locator('.duplicate-warning')).toBeVisible({
timeout: 10000
});
await expect(dialog.locator('.duplicate-warning')).toContainText(/existe déjà/i);
// Click "Annuler" — warning disappears
await dialog
.locator('.duplicate-warning')
.getByRole('button', { name: /annuler/i })
.click();
await expect(dialog.locator('.duplicate-warning')).not.toBeVisible();
// Submit again — warning reappears
await dialog.getByRole('button', { name: /créer l'élève/i }).click();
await expect(dialog.locator('.duplicate-warning')).toBeVisible({
timeout: 10000
});
// Click "Continuer" — creation succeeds despite duplicate
await dialog
.locator('.duplicate-warning')
.getByRole('button', { name: /continuer/i })
.click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC4: Students listing page
// ============================================================================
test.describe('AC4 - Students List', () => {
test('displays students in a table', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Table should have headers
await expect(page.locator('.students-table th', { hasText: /nom/i })).toBeVisible();
await expect(page.locator('.students-table th', { hasText: /classe/i })).toBeVisible();
await expect(page.locator('.students-table th', { hasText: /statut/i })).toBeVisible();
});
test('search filters students by name', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Search for a student created earlier
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Should find the student (use .first() because AC3 duplicate test creates a second one)
await expect(
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first()
).toBeVisible({ timeout: 10000 });
});
test('rows are clickable and navigate to student detail', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Search for specific student
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Click on the row (use .first() because AC3 duplicate test creates a second one)
const row = page.locator('.clickable-row', {
hasText: `Dupont-${UNIQUE_SUFFIX}`
}).first();
await row.click();
// Should navigate to student detail page
await expect(page).toHaveURL(/\/admin\/students\/[a-f0-9-]+/);
await expect(
page.getByRole('heading', { name: /fiche élève/i })
).toBeVisible({ timeout: 10000 });
});
test('[P1] pagination appears when more than 30 students and navigation works', async ({
page
}) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Pagination nav should be visible (31 students created in beforeAll)
const paginationNav = page.locator('nav[aria-label="Pagination"]');
await expect(paginationNav).toBeVisible({ timeout: 10000 });
// "Précédent" button should be disabled on page 1
await expect(
paginationNav.getByRole('button', { name: /précédent/i })
).toBeDisabled();
// Page 1 button should be active
await expect(
paginationNav.getByRole('button', { name: 'Page 1', exact: true })
).toHaveAttribute('aria-current', 'page');
// Click "Suivant" to go to page 2
await paginationNav.getByRole('button', { name: /suivant/i }).click();
await page.waitForLoadState('networkidle');
// URL should contain page=2
await expect(page).toHaveURL(/page=2/);
// Page 2 button should now be active
await expect(
paginationNav.getByRole('button', { name: 'Page 2', exact: true })
).toHaveAttribute('aria-current', 'page');
// "Précédent" should now be enabled
await expect(
paginationNav.getByRole('button', { name: /précédent/i })
).toBeEnabled();
// Table or content should still be visible (not error)
await expect(
page.locator('.students-table, .empty-state')
).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC5: Change student class
// ============================================================================
test.describe('AC5 - Change Student Class', () => {
test('[P1] can change class via modal with confirmation and optimistic update', async ({
page
}) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Search for a student created earlier
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(`Dupont-${UNIQUE_SUFFIX}`);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the student row and click "Changer de classe" (use .first() because AC3 duplicate test creates a second one)
const row = page.locator('.clickable-row', {
hasText: `Dupont-${UNIQUE_SUFFIX}`
}).first();
await expect(row).toBeVisible({ timeout: 10000 });
await row.locator('button', { hasText: /changer de classe/i }).click();
// Change class modal should open
const dialog = page.locator('[role="alertdialog"]');
await expect(dialog).toBeVisible({ timeout: 5000 });
await expect(
dialog.getByRole('heading', { name: /changer de classe/i })
).toBeVisible();
// Description should mention the student name
await expect(dialog.locator('#change-class-description')).toContainText(
`Dupont-${UNIQUE_SUFFIX}`
);
// Select a different class
await dialog.locator('#change-class-select').selectOption({ index: 1 });
// Confirmation text should appear
await expect(dialog.locator('.change-confirm-info')).toBeVisible();
await expect(dialog.locator('.change-confirm-info')).toContainText(/transférer/i);
// Click "Confirmer le transfert"
await dialog.getByRole('button', { name: /confirmer le transfert/i }).click();
// Success message should appear
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toContainText(/transféré/i);
// Modal should close
await expect(dialog).not.toBeVisible();
});
});
// ============================================================================
// AC6: Filter by class
// ============================================================================
test.describe('AC6 - Class Filter', () => {
test('class filter dropdown exists with optgroups', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
const filterSelect = page.locator('#filter-class');
await expect(filterSelect).toBeVisible();
// Should have optgroups
const optgroups = filterSelect.locator('optgroup');
const count = await optgroups.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test('[P2] selecting a class filters the student list', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await waitForStudentsPage(page);
// Select a class in the filter
const filterSelect = page.locator('#filter-class');
await filterSelect.selectOption({ index: 1 });
// Wait for the list to reload
await page.waitForLoadState('networkidle');
// URL should contain classId parameter
await expect(page).toHaveURL(/classId=/);
// The page should still show the table or empty state (not an error)
await expect(
page.locator('.students-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Reset filter
await filterSelect.selectOption({ value: '' });
await page.waitForLoadState('networkidle');
// classId should be removed from URL (polling assertion for reliability)
await expect(page).not.toHaveURL(/classId=/, { timeout: 10000 });
});
});
});