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