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:
@@ -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' }
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
150
frontend/src/lib/features/students/api/students.ts
Normal file
150
frontend/src/lib/features/students/api/students.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
1292
frontend/src/routes/admin/students/+page.svelte
Normal file
1292
frontend/src/routes/admin/students/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="student-detail">
|
||||
<header class="page-header">
|
||||
<a href="/admin/users" class="back-link">← Retour</a>
|
||||
<a href="/admin/students" class="back-link">← Retour</a>
|
||||
<h1>Fiche élève</h1>
|
||||
</header>
|
||||
|
||||
|
||||
300
frontend/tests/unit/lib/features/students/api/students.test.ts
Normal file
300
frontend/tests/unit/lib/features/students/api/students.test.ts
Normal 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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user