Files
Classeo/frontend/e2e/student-creation.spec.ts
Mathias STRASSER 713e408773
Some checks failed
CI / Naming Conventions (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
2026-04-10 15:24:27 +02: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: 60000 }),
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: 20000 });
});
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 });
});
});
});