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:
2026-02-23 19:12:21 +01:00
parent e5203097ef
commit 560b941821
49 changed files with 5184 additions and 65 deletions

View File

@@ -30,6 +30,10 @@ test.describe('Classes Management (Story 2.1)', () => {
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM class_assignments WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
@@ -78,6 +82,10 @@ test.describe('Classes Management (Story 2.1)', () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM class_assignments WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }

View 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 });
});
});
});

View File

@@ -126,7 +126,7 @@ test.describe('Student Management', () => {
await expect(page).toHaveTitle(/fiche élève/i);
});
test('back link navigates to users page', async ({ page }) => {
test('back link navigates to students page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students/${studentUserId}`);
@@ -136,8 +136,8 @@ test.describe('Student Management', () => {
// Click the back link
await page.locator('.back-link').click();
// Should navigate to users page
await expect(page).toHaveURL(/\/admin\/users/);
// Should navigate to students page
await expect(page).toHaveURL(/\/admin\/students/);
});
});

View File

@@ -36,6 +36,11 @@
<span class="action-label">Configurer les classes</span>
<span class="action-hint">Créer et gérer</span>
</a>
<a class="action-card" href="/admin/students">
<span class="action-icon">🎒</span>
<span class="action-label">Gérer les élèves</span>
<span class="action-hint">Inscrire et affecter</span>
</a>
<a class="action-card" href="/admin/subjects">
<span class="action-icon">📚</span>
<span class="action-label">Gérer les matières</span>

View File

@@ -0,0 +1,150 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
export interface Student {
id: string;
firstName: string;
lastName: string;
email: string | null;
classId: string | null;
className: string | null;
classLevel: string | null;
statut: string;
studentNumber: string | null;
dateNaissance: string | null;
}
export interface SchoolClass {
id: string;
name: string;
level: string | null;
}
export interface FetchStudentsParams {
page: number;
itemsPerPage: number;
search?: string | undefined;
classId?: string | undefined;
signal?: AbortSignal | undefined;
}
export interface FetchStudentsResult {
members: Student[];
totalItems: number;
}
export interface CreateStudentData {
firstName: string;
lastName: string;
classId: string;
email?: string | undefined;
dateNaissance?: string | undefined;
studentNumber?: string | undefined;
}
/**
* Récupère la liste paginée des élèves.
*/
export async function fetchStudents(params: FetchStudentsParams): Promise<FetchStudentsResult> {
const apiUrl = getApiBaseUrl();
const searchParams = new URLSearchParams();
searchParams.set('page', String(params.page));
searchParams.set('itemsPerPage', String(params.itemsPerPage));
if (params.search) searchParams.set('search', params.search);
if (params.classId) searchParams.set('classId', params.classId);
const options: RequestInit = {};
if (params.signal) options.signal = params.signal;
const response = await authenticatedFetch(
`${apiUrl}/students?${searchParams.toString()}`,
options
);
if (!response.ok) {
throw new Error('Erreur lors du chargement des élèves');
}
const data = await response.json();
const members: Student[] =
data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
const totalItems: number = data['hydra:totalItems'] ?? data['totalItems'] ?? members.length;
return { members, totalItems };
}
/**
* Récupère la liste des classes disponibles.
*/
export async function fetchClasses(): Promise<SchoolClass[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/classes?itemsPerPage=200`);
if (!response.ok) {
throw new Error('Erreur lors du chargement des classes');
}
const data = await response.json();
return data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
}
/**
* Crée un nouvel élève.
*/
export async function createStudent(studentData: CreateStudentData): Promise<Student> {
const apiUrl = getApiBaseUrl();
const body: Record<string, string> = {
firstName: studentData.firstName,
lastName: studentData.lastName,
classId: studentData.classId
};
if (studentData.email) body['email'] = studentData.email;
if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance;
if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber;
const response = await authenticatedFetch(`${apiUrl}/students`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
let errorMessage = `Erreur lors de la création (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) errorMessage = errorData['hydra:description'];
else if (errorData.message) errorMessage = errorData.message;
else if (errorData.detail) errorMessage = errorData.detail;
} catch {
// JSON parsing failed
}
throw new Error(errorMessage);
}
return await response.json();
}
/**
* Change la classe d'un élève.
*/
export async function changeStudentClass(studentId: string, newClassId: string): Promise<void> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/class`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({ classId: newClassId })
});
if (!response.ok) {
let errorMessage = `Erreur lors du changement de classe (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) errorMessage = errorData['hydra:description'];
else if (errorData.message) errorMessage = errorData.message;
else if (errorData.detail) errorMessage = errorData.detail;
} catch {
// JSON parsing failed
}
throw new Error(errorMessage);
}
}

View File

@@ -25,6 +25,7 @@
const navLinks = [
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
{ href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive },
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
{ href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive },
@@ -81,6 +82,7 @@
// Determine which admin section is active
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students'));
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
<div class="student-detail">
<header class="page-header">
<a href="/admin/users" class="back-link">&larr; Retour</a>
<a href="/admin/students" class="back-link">&larr; Retour</a>
<h1>Fiche élève</h1>
</header>

View File

@@ -0,0 +1,300 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('$lib/api', () => ({
getApiBaseUrl: () => 'http://test.classeo.local:18000/api'
}));
const mockAuthenticatedFetch = vi.fn();
vi.mock('$lib/auth', () => ({
authenticatedFetch: (...args: unknown[]) => mockAuthenticatedFetch(...args)
}));
import {
fetchStudents,
fetchClasses,
createStudent,
changeStudentClass
} from '$lib/features/students/api/students';
describe('students API', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ==========================================================================
// fetchStudents
// ==========================================================================
describe('fetchStudents', () => {
it('should return members and totalItems on success', async () => {
const mockStudents = [
{
id: 'student-1',
firstName: 'Marie',
lastName: 'Dupont',
email: null,
classId: 'class-1',
className: '6ème A',
classLevel: 'sixieme',
statut: 'inscrit',
studentNumber: null,
dateNaissance: null
}
];
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
'hydra:member': mockStudents,
'hydra:totalItems': 1
})
});
const result = await fetchStudents({ page: 1, itemsPerPage: 30 });
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
'http://test.classeo.local:18000/api/students?page=1&itemsPerPage=30',
{}
);
expect(result.members).toHaveLength(1);
expect(result.members[0]!.firstName).toBe('Marie');
expect(result.totalItems).toBe(1);
});
it('should pass search and classId params when provided', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
'hydra:member': [],
'hydra:totalItems': 0
})
});
await fetchStudents({
page: 2,
itemsPerPage: 30,
search: 'Dupont',
classId: 'class-1'
});
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
'http://test.classeo.local:18000/api/students?page=2&itemsPerPage=30&search=Dupont&classId=class-1',
{}
);
});
it('should throw when API response is not ok', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: false,
status: 500
});
await expect(fetchStudents({ page: 1, itemsPerPage: 30 })).rejects.toThrow(
'Erreur lors du chargement des élèves'
);
});
});
// ==========================================================================
// fetchClasses
// ==========================================================================
describe('fetchClasses', () => {
it('should return classes array on success', async () => {
const mockClasses = [
{ id: 'class-1', name: '6ème A', level: 'sixieme' },
{ id: 'class-2', name: '5ème B', level: 'cinquieme' }
];
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ 'hydra:member': mockClasses })
});
const result = await fetchClasses();
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
'http://test.classeo.local:18000/api/classes?itemsPerPage=200'
);
expect(result).toHaveLength(2);
expect(result[0]!.name).toBe('6ème A');
});
it('should throw when API response is not ok', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: false,
status: 500
});
await expect(fetchClasses()).rejects.toThrow('Erreur lors du chargement des classes');
});
});
// ==========================================================================
// createStudent
// ==========================================================================
describe('createStudent', () => {
it('should return created student on success', async () => {
const created = {
id: 'new-student-id',
firstName: 'Marie',
lastName: 'Dupont',
email: null,
classId: 'class-1',
className: '6ème A',
classLevel: 'sixieme',
statut: 'inscrit',
studentNumber: null,
dateNaissance: null
};
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(created)
});
const result = await createStudent({
firstName: 'Marie',
lastName: 'Dupont',
classId: 'class-1'
});
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
'http://test.classeo.local:18000/api/students',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: 'Marie',
lastName: 'Dupont',
classId: 'class-1'
})
})
);
expect(result.id).toBe('new-student-id');
expect(result.firstName).toBe('Marie');
});
it('should include optional fields when provided', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
id: 'new-id',
firstName: 'Marie',
lastName: 'Dupont',
email: 'marie@example.com',
classId: 'class-1',
className: '6ème A',
classLevel: 'sixieme',
statut: 'pending',
studentNumber: '12345',
dateNaissance: '2015-06-15'
})
});
await createStudent({
firstName: 'Marie',
lastName: 'Dupont',
classId: 'class-1',
email: 'marie@example.com',
dateNaissance: '2015-06-15',
studentNumber: '12345'
});
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
'http://test.classeo.local:18000/api/students',
expect.objectContaining({
body: JSON.stringify({
firstName: 'Marie',
lastName: 'Dupont',
classId: 'class-1',
email: 'marie@example.com',
dateNaissance: '2015-06-15',
studentNumber: '12345'
})
})
);
});
it('should throw with hydra:description on error', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: false,
status: 422,
json: () =>
Promise.resolve({
'hydra:description': 'Cet email est déjà utilisé.'
})
});
await expect(
createStudent({ firstName: 'Marie', lastName: 'Dupont', classId: 'class-1' })
).rejects.toThrow('Cet email est déjà utilisé.');
});
it('should throw generic message when error body is not valid JSON', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Unexpected token'))
});
await expect(
createStudent({ firstName: 'Marie', lastName: 'Dupont', classId: 'class-1' })
).rejects.toThrow('Erreur lors de la création (500)');
});
});
// ==========================================================================
// changeStudentClass
// ==========================================================================
describe('changeStudentClass', () => {
it('should call PATCH endpoint with correct body', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: true
});
await changeStudentClass('student-1', 'class-2');
expect(mockAuthenticatedFetch).toHaveBeenCalledWith(
'http://test.classeo.local:18000/api/students/student-1/class',
expect.objectContaining({
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({ classId: 'class-2' })
})
);
});
it('should throw with hydra:description on error', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: () =>
Promise.resolve({
'hydra:description': 'Élève non trouvé.'
})
});
await expect(changeStudentClass('student-1', 'class-2')).rejects.toThrow(
'Élève non trouvé.'
);
});
it('should throw generic message when error body is not valid JSON', async () => {
mockAuthenticatedFetch.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.reject(new Error('Unexpected token'))
});
await expect(changeStudentClass('student-1', 'class-2')).rejects.toThrow(
'Erreur lors du changement de classe (500)'
);
});
});
});