repository = new InMemoryPeriodConfigurationRepository(); $this->clock = new class implements Clock { public function now(): DateTimeImmutable { return new DateTimeImmutable('2025-10-15 10:00:00'); } }; $this->eventBus = new class implements MessageBusInterface { public function dispatch(object $message, array $stamps = []): Envelope { return new Envelope($message); } }; $this->handler = new UpdatePeriodHandler($this->repository, new NoOpGradeExistenceChecker(), $this->clock, $this->eventBus); } #[Test] public function itUpdatesPeriodDates(): void { $this->seedTrimesterConfig(); $command = new UpdatePeriodCommand( tenantId: self::TENANT_ID, academicYearId: self::ACADEMIC_YEAR_ID, sequence: 1, startDate: '2025-09-01', endDate: '2025-12-15', ); $this->expectException(PeriodsOverlapException::class); ($this->handler)($command); } #[Test] public function itUpdatesValidPeriodDates(): void { $this->seedTrimesterConfig(); $command = new UpdatePeriodCommand( tenantId: self::TENANT_ID, academicYearId: self::ACADEMIC_YEAR_ID, sequence: 1, startDate: '2025-09-02', endDate: '2025-11-30', ); $config = ($this->handler)($command); self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d')); self::assertSame('2025-11-30', $config->periods[0]->endDate->format('Y-m-d')); } #[Test] public function itRejectsWhenNoConfigurationExists(): void { $this->expectException(PeriodesNonConfigureesException::class); ($this->handler)(new UpdatePeriodCommand( tenantId: self::TENANT_ID, academicYearId: self::ACADEMIC_YEAR_ID, sequence: 1, startDate: '2025-09-01', endDate: '2025-11-30', )); } #[Test] public function itRejectsUnknownSequence(): void { $this->seedTrimesterConfig(); $this->expectException(PeriodeNonTrouveeException::class); ($this->handler)(new UpdatePeriodCommand( tenantId: self::TENANT_ID, academicYearId: self::ACADEMIC_YEAR_ID, sequence: 99, startDate: '2025-09-01', endDate: '2025-11-30', )); } #[Test] public function itRejectsUpdateWhenPeriodHasGradesWithoutConfirmation(): void { $this->seedTrimesterConfig(); $gradeChecker = new class implements GradeExistenceChecker { #[Override] public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool { return true; } }; $handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus); $this->expectException(PeriodeAvecNotesException::class); $handler(new UpdatePeriodCommand( tenantId: self::TENANT_ID, academicYearId: self::ACADEMIC_YEAR_ID, sequence: 1, startDate: '2025-09-02', endDate: '2025-11-30', )); } #[Test] public function itAllowsUpdateWhenPeriodHasGradesWithConfirmation(): void { $this->seedTrimesterConfig(); $gradeChecker = new class implements GradeExistenceChecker { #[Override] public function hasGradesInPeriod(TenantId $tenantId, AcademicYearId $academicYearId, int $periodSequence): bool { return true; } }; $handler = new UpdatePeriodHandler($this->repository, $gradeChecker, $this->clock, $this->eventBus); $config = $handler(new UpdatePeriodCommand( tenantId: self::TENANT_ID, academicYearId: self::ACADEMIC_YEAR_ID, sequence: 1, startDate: '2025-09-02', endDate: '2025-11-30', confirmImpact: true, )); self::assertSame('2025-09-02', $config->periods[0]->startDate->format('Y-m-d')); } private function seedTrimesterConfig(): void { $config = DefaultPeriods::forType(PeriodType::TRIMESTER, 2025); $this->repository->save( TenantId::fromString(self::TENANT_ID), AcademicYearId::fromString(self::ACADEMIC_YEAR_ID), $config, ); } }