Files
Classeo/frontend/tests/unit/lib/features/students/api/students.test.ts
Mathias STRASSER 560b941821 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.
2026-02-24 11:53:02 +01:00

301 lines
7.9 KiB
TypeScript

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