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:
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Administration\Application\Command\AssignStudentToClass;
|
||||
|
||||
use App\Administration\Application\Command\AssignStudentToClass\AssignStudentToClassCommand;
|
||||
use App\Administration\Application\Command\AssignStudentToClass\AssignStudentToClassHandler;
|
||||
use App\Administration\Domain\Exception\ClasseNotFoundException;
|
||||
use App\Administration\Domain\Exception\EleveDejaAffecteException;
|
||||
use App\Administration\Domain\Exception\UserNotFoundException;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassStatus;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolLevel;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
|
||||
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryUserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AssignStudentToClassHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string ACADEMIC_YEAR_ID = '550e8400-e29b-41d4-a716-446655440040';
|
||||
private const string SCHOOL_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440060';
|
||||
|
||||
private InMemoryClassAssignmentRepository $classAssignmentRepository;
|
||||
private InMemoryUserRepository $userRepository;
|
||||
private InMemoryClassRepository $classRepository;
|
||||
private Clock $clock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
|
||||
$this->userRepository = new InMemoryUserRepository();
|
||||
$this->classRepository = new InMemoryClassRepository();
|
||||
$this->clock = new class implements Clock {
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('2026-02-21 10:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
$this->seedTestData();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itAssignsStudentToClass(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand();
|
||||
|
||||
$assignment = $handler($command);
|
||||
|
||||
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID)));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenStudentDoesNotExist(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(studentId: '550e8400-e29b-41d4-a716-446655440099');
|
||||
|
||||
$this->expectException(UserNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassDoesNotExist(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: '550e8400-e29b-41d4-a716-446655440099');
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenStudentAlreadyAssigned(): void
|
||||
{
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand();
|
||||
|
||||
// First assignment succeeds
|
||||
$handler($command);
|
||||
|
||||
$this->expectException(EleveDejaAffecteException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassBelongsToAnotherTenant(): void
|
||||
{
|
||||
$otherTenantClassId = '550e8400-e29b-41d4-a716-446655440030';
|
||||
|
||||
$classDifferentTenant = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($otherTenantClassId),
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème B'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($classDifferentTenant);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: $otherTenantClassId);
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenStudentBelongsToAnotherTenant(): void
|
||||
{
|
||||
$otherTenantStudentId = '550e8400-e29b-41d4-a716-446655440070';
|
||||
|
||||
$otherTenantStudent = User::reconstitute(
|
||||
id: UserId::fromString($otherTenantStudentId),
|
||||
email: null,
|
||||
roles: [Role::ELEVE],
|
||||
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
|
||||
schoolName: 'Autre École',
|
||||
statut: StatutCompte::INSCRIT,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
hashedPassword: null,
|
||||
activatedAt: null,
|
||||
consentementParental: null,
|
||||
);
|
||||
$this->userRepository->save($otherTenantStudent);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(studentId: $otherTenantStudentId);
|
||||
|
||||
$this->expectException(UserNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function itThrowsWhenClassIsArchived(): void
|
||||
{
|
||||
$archivedClassId = '550e8400-e29b-41d4-a716-446655440031';
|
||||
|
||||
$archivedClass = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString($archivedClassId),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème C'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ARCHIVED,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($archivedClass);
|
||||
|
||||
$handler = $this->createHandler();
|
||||
$command = $this->createCommand(classId: $archivedClassId);
|
||||
|
||||
$this->expectException(ClasseNotFoundException::class);
|
||||
$handler($command);
|
||||
}
|
||||
|
||||
private function seedTestData(): void
|
||||
{
|
||||
$class = SchoolClass::reconstitute(
|
||||
id: ClassId::fromString(self::CLASS_ID),
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolId: SchoolId::fromString(self::SCHOOL_ID),
|
||||
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
|
||||
name: new ClassName('6ème A'),
|
||||
level: SchoolLevel::SIXIEME,
|
||||
capacity: 30,
|
||||
status: ClassStatus::ACTIVE,
|
||||
description: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
updatedAt: new DateTimeImmutable('2026-01-15'),
|
||||
deletedAt: null,
|
||||
);
|
||||
$this->classRepository->save($class);
|
||||
|
||||
$student = User::reconstitute(
|
||||
id: UserId::fromString(self::STUDENT_ID),
|
||||
email: null,
|
||||
roles: [Role::ELEVE],
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
schoolName: 'École Test',
|
||||
statut: StatutCompte::INSCRIT,
|
||||
dateNaissance: null,
|
||||
createdAt: new DateTimeImmutable('2026-01-15'),
|
||||
hashedPassword: null,
|
||||
activatedAt: null,
|
||||
consentementParental: null,
|
||||
);
|
||||
$this->userRepository->save($student);
|
||||
}
|
||||
|
||||
private function createHandler(): AssignStudentToClassHandler
|
||||
{
|
||||
return new AssignStudentToClassHandler(
|
||||
$this->classAssignmentRepository,
|
||||
$this->userRepository,
|
||||
$this->classRepository,
|
||||
$this->clock,
|
||||
);
|
||||
}
|
||||
|
||||
private function createCommand(
|
||||
?string $studentId = null,
|
||||
?string $classId = null,
|
||||
): AssignStudentToClassCommand {
|
||||
return new AssignStudentToClassCommand(
|
||||
tenantId: self::TENANT_ID,
|
||||
studentId: $studentId ?? self::STUDENT_ID,
|
||||
classId: $classId ?? self::CLASS_ID,
|
||||
academicYearId: self::ACADEMIC_YEAR_ID,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user