feat: Permettre la création manuelle d'élèves et leur affectation aux classes
Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un élève en cours d'année sans passer par un import CSV. Cette fonctionnalité pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui sera réutilisé par l'import CSV en masse (Story 3.1). L'email est désormais optionnel pour les élèves : si fourni, une invitation est envoyée (User::inviter) ; sinon l'élève est créé avec le statut INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur et l'affectation à la classe sont atomiques (transaction DBAL). Côté frontend, la page /admin/students offre liste paginée, recherche, filtrage par classe, création via modale (avec détection de doublons côté serveur), et changement de classe avec optimistic update.
This commit is contained in:
642
frontend/e2e/student-creation.spec.ts
Normal file
642
frontend/e2e/student-creation.spec.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
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`);
|
||||
|
||||
// The nav should have an active "Élèves" link
|
||||
await expect(page.locator('nav a', { hasText: /é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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user