buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; // oldest = 39h $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]); $resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee); self::assertInstanceOf(ContractPhase::class, $resolved); self::assertSame($h39Phase->id, $resolved->id); self::assertSame(ContractType::H39, $resolved->contractType); self::assertFalse($resolved->isCurrent); } public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $currentPhase = $phases[0]; // most recent = FORFAIT $provider = $this->buildProvider([]); $resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee); self::assertSame($currentPhase->id, $resolved->id); self::assertTrue($resolved->isCurrent); } public function testPastH39PhaseRttSummaryIsCappedAtPhaseEndDate(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']); $year = $this->invokePrivate($provider, 'resolveYear', $h39Phase); self::assertSame(2026, $year); // The phase-cap branch in provide() narrows periodTo to the phase end date. // Reproduce that logic to validate the resulting window. $periodFrom = new DateTimeImmutable('2025-06-01'); $periodTo = new DateTimeImmutable('2026-05-31'); if (!$h39Phase->isCurrent && null !== $h39Phase->endDate && $h39Phase->endDate < $periodTo) { $periodTo = $h39Phase->endDate; } if ($h39Phase->startDate > $periodFrom) { $periodFrom = $h39Phase->startDate; } self::assertSame('2025-06-01', $periodFrom->format('Y-m-d')); self::assertSame('2026-04-30', $periodTo->format('Y-m-d')); } public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']); $year = $this->invokePrivate($provider, 'resolveYear', $h39Phase); // Phase ends 2026-04-30 → exercice (Juin-Mai) containing it = 2026. self::assertSame(2026, $year); } public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; // Phase starts 2020-06-01 → first exercise (Juin-Mai) = 2021. $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']); $year = $this->invokePrivate($provider, 'resolveYear', $h39Phase); self::assertSame(2021, $year); } public function testInvalidPhaseIdReturns422(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $provider = $this->buildProvider(['phaseId' => '99999']); $this->expectException(UnprocessableEntityHttpException::class); $this->invokePrivate($provider, 'resolveTargetPhase', $employee); } public function testNonNumericPhaseIdReturns422(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $provider = $this->buildProvider(['phaseId' => 'abc']); $this->expectException(UnprocessableEntityHttpException::class); $this->invokePrivate($provider, 'resolveTargetPhase', $employee); } public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void { // No `year` param + explicit phaseId → default year is derived from $phase->endDate. // H39 phase ends 2026-04-30 → RTT exercise containing that date = 2026. $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $h39Phase = $phases[1]; $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]); $year = $this->invokePrivate($provider, 'resolveYear', $h39Phase); self::assertSame(2026, $year); } public function testNoQueryParamsKeepsLegacyYearDefaulting(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $currentPhase = $phases[0]; $provider = $this->buildProvider([]); $year = $this->invokePrivate($provider, 'resolveYear', $currentPhase); // Today is 2026-05-19 → current RTT exercise (Juin N-1 → Mai N) = 2026. self::assertSame(2026, $year); } public function testInvalidYearFormatReturns422(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $currentPhase = $phases[0]; $provider = $this->buildProvider(['year' => '20XX']); $this->expectException(UnprocessableEntityHttpException::class); $this->invokePrivate($provider, 'resolveYear', $currentPhase); } public function testYearOutsideBoundsReturns422(): void { $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $currentPhase = $phases[0]; $provider = $this->buildProvider(['year' => '1900']); $this->expectException(UnprocessableEntityHttpException::class); $this->invokePrivate($provider, 'resolveYear', $currentPhase); } public function testYearWithoutPhaseIdIsNotClamped(): void { // No `phaseId` → legacy callers must keep their requested year as-is, // even if it falls outside the current phase range. $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01'); $phases = new EmployeeContractPhaseResolver()->resolvePhases($employee); $currentPhase = $phases[0]; $provider = $this->buildProvider(['year' => '2030']); $year = $this->invokePrivate($provider, 'resolveYear', $currentPhase); self::assertSame(2030, $year); } // ----------------------------------------------------------------------- // Test harness helpers. // ----------------------------------------------------------------------- /** * Build a two-period employee transitioning from H39 to FORFAIT. */ private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee { $employee = new Employee(); $this->setEntityId($employee, 1); $h39Contract = new Contract(); $h39Contract->setName('39H'); $h39Contract->setTrackingMode(TrackingMode::TIME->value); $h39Contract->setWeeklyHours(39); $forfaitContract = new Contract(); $forfaitContract->setName('Forfait'); $forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value); $forfaitContract->setWeeklyHours(null); $h39Period = new EmployeeContractPeriod(); $this->setEntityId($h39Period, 1); $h39Period->setEmployee($employee); $h39Period->setContract($h39Contract); $h39Period->setStartDate(new DateTimeImmutable($h39Start)); $h39Period->setEndDate(new DateTimeImmutable($h39End)); $h39Period->setContractNature(ContractNature::CDI); $h39Period->setIsDriver(false); $forfaitPeriod = new EmployeeContractPeriod(); $this->setEntityId($forfaitPeriod, 2); $forfaitPeriod->setEmployee($employee); $forfaitPeriod->setContract($forfaitContract); $forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart)); $forfaitPeriod->setEndDate(null); $forfaitPeriod->setContractNature(ContractNature::CDI); $forfaitPeriod->setIsDriver(false); $employee->getContractPeriods()->add($h39Period); $employee->getContractPeriods()->add($forfaitPeriod); return $employee; } /** * Build an uninitialized provider with a RequestStack pre-loaded with the given query. * * The provider's repository/service dependencies are typed against final classes * (EmployeeRepository, RttRecoveryComputationService, etc.) which PHPUnit cannot * double. We bypass full instantiation by using newInstanceWithoutConstructor and * only setting the properties that the tested private methods actually read: * `requestStack` and `phaseResolver`. * * @param array $request query parameters (year, phaseId, ...) */ private function buildProvider(array $request = []): EmployeeRttSummaryProvider { $requestStack = new RequestStack(); $requestStack->push(new Request(query: $request)); $reflection = new ReflectionClass(EmployeeRttSummaryProvider::class); $provider = $reflection->newInstanceWithoutConstructor(); $this->setReadonlyProperty($provider, 'requestStack', $requestStack); $this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver()); $this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver()); $this->setReadonlyProperty($provider, 'rttStartDate', null); return $provider; } private function invokePrivate(object $obj, string $method, mixed ...$args): mixed { $reflection = new ReflectionClass($obj::class); $m = $reflection->getMethod($method); return $m->invoke($obj, ...$args); } private function setReadonlyProperty(object $obj, string $property, mixed $value): void { $reflection = new ReflectionProperty($obj::class, $property); $reflection->setValue($obj, $value); } private function setEntityId(object $entity, int $id): void { $reflection = new ReflectionProperty($entity::class, 'id'); $reflection->setValue($entity, $id); } }