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:
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