Le menu d'administration contenait 13 liens à plat dans le header, ce qui débordait sur desktop et rendait le drawer mobile trop long à scanner. Les liens sont maintenant regroupés en 4 catégories (Personnes, Organisation, Année scolaire, Paramètres) avec des dropdowns au survol sur desktop et des accordéons repliables dans le drawer mobile. Le nombre d'éléments visibles passe de 13 à 5 (1 lien direct + 4 catégories), la catégorie active s'auto-déplie dans le menu mobile.
645 lines
25 KiB
TypeScript
645 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 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);
|
|
});
|
|
|
|
// ============================================================================
|
|
// 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 });
|
|
|
|
// 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 });
|
|
});
|
|
});
|
|
});
|