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

@@ -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,
);
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\ChangeStudentClass;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
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\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ChangeStudentClassHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string OLD_CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string NEW_CLASS_ID = '550e8400-e29b-41d4-a716-446655440021';
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 InMemoryClassRepository $classRepository;
private Clock $clock;
protected function setUp(): void
{
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
$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 itChangesStudentClass(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$assignment = $handler($command);
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::NEW_CLASS_ID)));
}
#[Test]
public function itThrowsWhenNewClassDoesNotExist(): void
{
$handler = $this->createHandler();
$command = $this->createCommand(newClassId: '550e8400-e29b-41d4-a716-446655440099');
$this->expectException(ClasseNotFoundException::class);
$handler($command);
}
#[Test]
public function itThrowsWhenAssignmentNotFound(): void
{
$handler = $this->createHandler();
$command = $this->createCommand(studentId: '550e8400-e29b-41d4-a716-446655440070');
$this->expectException(AffectationEleveNonTrouveeException::class);
$handler($command);
}
#[Test]
public function itThrowsWhenClassBelongsToAnotherTenant(): void
{
$crossTenantClassId = '550e8400-e29b-41d4-a716-446655440030';
$this->classRepository->save(SchoolClass::reconstitute(
id: ClassId::fromString($crossTenantClassId),
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440002'),
schoolId: SchoolId::fromString(self::SCHOOL_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
name: new ClassName('5ème B'),
level: SchoolLevel::CINQUIEME,
capacity: 30,
status: ClassStatus::ACTIVE,
description: null,
createdAt: new DateTimeImmutable('2026-01-15'),
updatedAt: new DateTimeImmutable('2026-01-15'),
deletedAt: null,
));
$handler = $this->createHandler();
$command = $this->createCommand(newClassId: $crossTenantClassId);
$this->expectException(ClasseNotFoundException::class);
$handler($command);
}
#[Test]
public function itThrowsWhenClassIsArchived(): void
{
$archivedClassId = '550e8400-e29b-41d4-a716-446655440031';
$this->classRepository->save(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,
));
$handler = $this->createHandler();
$command = $this->createCommand(newClassId: $archivedClassId);
$this->expectException(ClasseNotFoundException::class);
$handler($command);
}
private function seedTestData(): void
{
$oldClass = SchoolClass::reconstitute(
id: ClassId::fromString(self::OLD_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($oldClass);
$newClass = SchoolClass::reconstitute(
id: ClassId::fromString(self::NEW_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 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($newClass);
$assignment = ClassAssignment::affecter(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::fromString(self::STUDENT_ID),
classId: ClassId::fromString(self::OLD_CLASS_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
assignedAt: new DateTimeImmutable('2026-01-20'),
);
$this->classAssignmentRepository->save($assignment);
}
private function createHandler(): ChangeStudentClassHandler
{
return new ChangeStudentClassHandler(
$this->classAssignmentRepository,
$this->classRepository,
$this->clock,
);
}
private function createCommand(
?string $newClassId = null,
?string $studentId = null,
): ChangeStudentClassCommand {
return new ChangeStudentClassCommand(
tenantId: self::TENANT_ID,
studentId: $studentId ?? self::STUDENT_ID,
newClassId: $newClassId ?? self::NEW_CLASS_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
);
}
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Administration\Application\Command\CreateStudent;
use App\Administration\Application\Command\CreateStudent\CreateStudentCommand;
use App\Administration\Application\Command\CreateStudent\CreateStudentHandler;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
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\Email;
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\Domain\Repository\UserRepository;
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 Doctrine\DBAL\Connection;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class CreateStudentHandlerTest 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 InMemoryUserRepository $userRepository;
private InMemoryClassAssignmentRepository $classAssignmentRepository;
private InMemoryClassRepository $classRepository;
private Connection $connection;
private Clock $clock;
protected function setUp(): void
{
$this->userRepository = new InMemoryUserRepository();
$this->classAssignmentRepository = new InMemoryClassAssignmentRepository();
$this->classRepository = new InMemoryClassRepository();
$this->connection = $this->createMock(Connection::class);
$this->connection->method('beginTransaction');
$this->connection->method('commit');
$this->clock = new class implements Clock {
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('2026-02-21 10:00:00');
}
};
$this->seedTestData();
}
#[Test]
public function itCreatesStudentWithEmail(): void
{
$handler = $this->createHandler();
$command = $this->createCommand(email: 'eleve@example.com');
$user = $handler($command);
self::assertSame('Marie', $user->firstName);
self::assertSame('Dupont', $user->lastName);
self::assertSame(StatutCompte::EN_ATTENTE, $user->statut);
self::assertSame('eleve@example.com', (string) $user->email);
self::assertTrue($user->aLeRole(Role::ELEVE));
}
#[Test]
public function itCreatesStudentWithoutEmail(): void
{
$handler = $this->createHandler();
$command = $this->createCommand(email: null);
$user = $handler($command);
self::assertSame('Marie', $user->firstName);
self::assertSame('Dupont', $user->lastName);
self::assertSame(StatutCompte::INSCRIT, $user->statut);
self::assertNull($user->email);
self::assertTrue($user->aLeRole(Role::ELEVE));
}
#[Test]
public function itAssignsStudentToClass(): void
{
$handler = $this->createHandler();
$command = $this->createCommand();
$user = $handler($command);
$assignment = $this->classAssignmentRepository->findByStudent(
$user->id,
AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
TenantId::fromString(self::TENANT_ID),
);
self::assertNotNull($assignment);
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::CLASS_ID)));
}
#[Test]
public function itSetsStudentNumber(): void
{
$handler = $this->createHandler();
$command = $this->createCommand(studentNumber: '12345678901');
$user = $handler($command);
self::assertSame('12345678901', $user->studentNumber);
}
#[Test]
public function itSetsDateNaissance(): void
{
$handler = $this->createHandler();
$command = $this->createCommand(dateNaissance: '2015-06-15');
$user = $handler($command);
self::assertNotNull($user->dateNaissance);
self::assertSame('2015-06-15', $user->dateNaissance->format('Y-m-d'));
}
#[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 itThrowsWhenEmailAlreadyUsedInTenant(): void
{
// Pre-populate with a user having the same email
$existing = User::reconstitute(
id: UserId::fromString('550e8400-e29b-41d4-a716-446655440060'),
email: new Email('existing@example.com'),
roles: [Role::ELEVE],
tenantId: TenantId::fromString(self::TENANT_ID),
schoolName: 'École Test',
statut: StatutCompte::EN_ATTENTE,
dateNaissance: null,
createdAt: new DateTimeImmutable('2026-01-15'),
hashedPassword: null,
activatedAt: null,
consentementParental: null,
);
$this->userRepository->save($existing);
$handler = $this->createHandler();
$command = $this->createCommand(email: 'existing@example.com');
$this->expectException(EmailDejaUtiliseeException::class);
$handler($command);
}
#[Test]
public function itRollsBackTransactionOnFailure(): void
{
$connection = $this->createMock(Connection::class);
$connection->expects(self::once())->method('beginTransaction');
$connection->expects(self::once())->method('rollBack');
$connection->expects(self::never())->method('commit');
// Use a mock UserRepository that throws during save (inside the transaction)
$failingUserRepo = $this->createMock(UserRepository::class);
$failingUserRepo->method('findByEmail')->willReturn(null);
$failingUserRepo->method('save')->willThrowException(new RuntimeException('DB write failed'));
$handler = new CreateStudentHandler(
$failingUserRepo,
$this->classAssignmentRepository,
$this->classRepository,
$connection,
$this->clock,
);
$command = $this->createCommand();
$this->expectException(RuntimeException::class);
$handler($command);
}
#[Test]
public function itThrowsWhenClassBelongsToAnotherTenant(): void
{
$otherTenantClassId = '550e8400-e29b-41d4-a716-446655440080';
$otherClass = SchoolClass::reconstitute(
id: ClassId::fromString($otherTenantClassId),
tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440099'),
schoolId: SchoolId::fromString(self::SCHOOL_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
name: new ClassName('Autre tenant'),
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($otherClass);
$handler = $this->createHandler();
$command = $this->createCommand(classId: $otherTenantClassId);
$this->expectException(ClasseNotFoundException::class);
$handler($command);
}
#[Test]
public function itThrowsWhenClassIsArchived(): void
{
$archivedClassId = '550e8400-e29b-41d4-a716-446655440081';
$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('Archivée'),
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);
}
private function createHandler(): CreateStudentHandler
{
return new CreateStudentHandler(
$this->userRepository,
$this->classAssignmentRepository,
$this->classRepository,
$this->connection,
$this->clock,
);
}
private function createCommand(
?string $email = 'eleve@example.com',
?string $classId = null,
?string $dateNaissance = null,
?string $studentNumber = null,
): CreateStudentCommand {
return new CreateStudentCommand(
tenantId: self::TENANT_ID,
schoolName: 'École Test',
firstName: 'Marie',
lastName: 'Dupont',
classId: $classId ?? self::CLASS_ID,
academicYearId: self::ACADEMIC_YEAR_ID,
email: $email,
dateNaissance: $dateNaissance,
studentNumber: $studentNumber,
);
}
}

View File

@@ -6,25 +6,36 @@ namespace App\Tests\Unit\Administration\Application\Query\HasStudentsInClass;
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassHandler;
use App\Administration\Application\Query\HasStudentsInClass\HasStudentsInClassQuery;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Infrastructure\Persistence\InMemory\InMemoryClassAssignmentRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Tests for HasStudentsInClassHandler.
*
* Currently returns 0 (stub) until the student module is available.
* These tests document the expected behavior for when the implementation
* is completed.
*/
final class HasStudentsInClassHandlerTest extends TestCase
{
#[Test]
public function returnsZeroForAnyClass(): void
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 InMemoryClassAssignmentRepository $repository;
protected function setUp(): void
{
$handler = new HasStudentsInClassHandler();
$this->repository = new InMemoryClassAssignmentRepository();
}
#[Test]
public function returnsZeroWhenNoStudentsAssigned(): void
{
$handler = new HasStudentsInClassHandler($this->repository);
$query = new HasStudentsInClassQuery(
classId: '550e8400-e29b-41d4-a716-446655440020',
classId: self::CLASS_ID,
);
$result = ($handler)($query);
@@ -33,28 +44,41 @@ final class HasStudentsInClassHandlerTest extends TestCase
}
#[Test]
public function returnsIntegerType(): void
public function returnsCountOfAssignedStudents(): void
{
$handler = new HasStudentsInClassHandler();
$this->addAssignment('550e8400-e29b-41d4-a716-446655440010');
$this->addAssignment('550e8400-e29b-41d4-a716-446655440011');
$query = new HasStudentsInClassQuery(
classId: '550e8400-e29b-41d4-a716-446655440021',
);
$handler = new HasStudentsInClassHandler($this->repository);
$result = ($handler)($query);
$result = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID));
self::assertIsInt($result);
self::assertSame(2, $result);
}
#[Test]
public function isConsistentAcrossMultipleCalls(): void
{
$handler = new HasStudentsInClassHandler();
$classId = '550e8400-e29b-41d4-a716-446655440022';
$this->addAssignment('550e8400-e29b-41d4-a716-446655440010');
$result1 = ($handler)(new HasStudentsInClassQuery(classId: $classId));
$result2 = ($handler)(new HasStudentsInClassQuery(classId: $classId));
$handler = new HasStudentsInClassHandler($this->repository);
$result1 = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID));
$result2 = ($handler)(new HasStudentsInClassQuery(classId: self::CLASS_ID));
self::assertSame($result1, $result2);
self::assertSame(1, $result1);
}
private function addAssignment(string $studentId): void
{
$assignment = ClassAssignment::affecter(
tenantId: TenantId::fromString(self::TENANT_ID),
studentId: UserId::fromString($studentId),
classId: ClassId::fromString(self::CLASS_ID),
academicYearId: AcademicYearId::fromString(self::ACADEMIC_YEAR_ID),
assignedAt: new DateTimeImmutable('2026-02-21 10:00:00'),
);
$this->repository->save($assignment);
}
}