repository = new InMemoryTeacherReplacementRepository(); $this->clock = new class implements Clock { public function now(): DateTimeImmutable { return new DateTimeImmutable('2026-04-15 10:00:00'); } }; } #[Test] public function itEndsExpiredReplacements(): void { $replacement = $this->createReplacement( startDate: '2026-03-01', endDate: '2026-03-31', ); $this->repository->save($replacement); $tester = $this->executeCommand(); self::assertSame(0, $tester->getStatusCode()); self::assertStringContainsString('1 remplacement(s) expiré(s) trouvé(s)', $tester->getDisplay()); self::assertStringContainsString('1 remplacement(s) terminé(s) avec succès.', $tester->getDisplay()); } #[Test] public function itHandlesNoExpiredReplacements(): void { $replacement = $this->createReplacement( startDate: '2026-04-01', endDate: '2026-04-30', ); $this->repository->save($replacement); $tester = $this->executeCommand(); self::assertSame(0, $tester->getStatusCode()); self::assertStringContainsString('Aucun remplacement expiré à traiter.', $tester->getDisplay()); } #[Test] public function itHandlesIndividualErrorsGracefully(): void { // The command's findExpired query uses one repository while the handler's // get() uses another that holds an already-terminated copy of the same // replacement, simulating a concurrent termination (race condition). $commandRepository = new InMemoryTeacherReplacementRepository(); $activeReplacement = $this->createReplacement( startDate: '2026-03-01', endDate: '2026-03-31', ); $commandRepository->save($activeReplacement); $handlerRepository = new InMemoryTeacherReplacementRepository(); $terminatedReplacement = TeacherReplacement::reconstitute( id: $activeReplacement->id, tenantId: $activeReplacement->tenantId, replacedTeacherId: $activeReplacement->replacedTeacherId, replacementTeacherId: $activeReplacement->replacementTeacherId, startDate: $activeReplacement->startDate, endDate: $activeReplacement->endDate, status: ReplacementStatus::ENDED, classes: $activeReplacement->classes, reason: $activeReplacement->reason, createdBy: $activeReplacement->createdBy, createdAt: $activeReplacement->createdAt, endedAt: new DateTimeImmutable('2026-03-31'), updatedAt: new DateTimeImmutable('2026-03-31'), ); $handlerRepository->save($terminatedReplacement); $handler = new EndReplacementHandler($handlerRepository, $this->clock); $command = new EndExpiredReplacementsCommand( $commandRepository, $handler, $this->clock, new NullLogger(), ); $tester = new CommandTester($command); $tester->execute([]); self::assertSame(0, $tester->getStatusCode()); self::assertStringContainsString('1 remplacement(s) expiré(s) trouvé(s)', $tester->getDisplay()); self::assertStringContainsString('déjà terminé', $tester->getDisplay()); self::assertStringContainsString('0 remplacement(s) terminé(s) avec succès.', $tester->getDisplay()); } #[Test] public function itOutputsCorrectMessagesForMultipleReplacements(): void { $replacement1 = $this->createReplacement( startDate: '2026-02-01', endDate: '2026-02-28', ); $replacement2 = $this->createReplacement( startDate: '2026-03-01', endDate: '2026-03-31', replacementTeacherId: '550e8400-e29b-41d4-a716-446655440012', ); $this->repository->save($replacement1); $this->repository->save($replacement2); $tester = $this->executeCommand(); self::assertSame(0, $tester->getStatusCode()); self::assertStringContainsString('2 remplacement(s) expiré(s) trouvé(s)', $tester->getDisplay()); self::assertStringContainsString('2 remplacement(s) terminé(s) avec succès.', $tester->getDisplay()); } private function executeCommand(): CommandTester { $handler = new EndReplacementHandler($this->repository, $this->clock); $command = new EndExpiredReplacementsCommand( $this->repository, $handler, $this->clock, new NullLogger(), ); $tester = new CommandTester($command); $tester->execute([]); return $tester; } private function createReplacement( string $startDate = '2026-03-01', string $endDate = '2026-03-31', string $replacementTeacherId = self::REPLACEMENT_TEACHER_ID, ): TeacherReplacement { return TeacherReplacement::designer( tenantId: TenantId::fromString(self::TENANT_ID), replacedTeacherId: UserId::fromString(self::REPLACED_TEACHER_ID), replacementTeacherId: UserId::fromString($replacementTeacherId), startDate: new DateTimeImmutable($startDate), endDate: new DateTimeImmutable($endDate), classes: [ new ClassSubjectPair( ClassId::fromString(self::CLASS_ID), SubjectId::fromString(self::SUBJECT_ID), ), ], reason: null, createdBy: UserId::fromString(self::CREATED_BY_ID), now: new DateTimeImmutable('2026-02-15 10:00:00'), ); } }