Gestion du changement de type de contrat + correction du calcule des RTT sur un contrat qui commence en milieu de semaine (#19)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [x] TU/TI/TF rédigée - [x] TU/TI/TF OK - [x] CHANGELOG modifié Reviewed-on: #19 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #19.
This commit is contained in:
196
tests/Service/Contracts/EmployeeContractPhaseResolverTest.php
Normal file
196
tests/Service/Contracts/EmployeeContractPhaseResolverTest.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Contracts;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EmployeeContractPhaseResolverTest extends TestCase
|
||||
{
|
||||
public function testSinglePeriodYieldsSinglePhaseMarkedCurrent(): void
|
||||
{
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
|
||||
self::assertCount(1, $phases);
|
||||
self::assertSame(ContractType::H39, $phases[0]->contractType);
|
||||
self::assertTrue($phases[0]->isCurrent);
|
||||
self::assertNull($phases[0]->endDate);
|
||||
self::assertSame(ContractNature::CDI, $phases[0]->contractNature);
|
||||
}
|
||||
|
||||
public function testThreeConsecutivePeriodsSameSignatureCollapseIntoSinglePhase(): void
|
||||
{
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2021-05-31'],
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-06-01', 'end' => '2022-05-31'],
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2022-06-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
|
||||
self::assertCount(1, $phases);
|
||||
self::assertCount(3, $phases[0]->periodIds);
|
||||
self::assertSame('2020-06-01', $phases[0]->startDate->format('Y-m-d'));
|
||||
self::assertNull($phases[0]->endDate);
|
||||
}
|
||||
|
||||
public function testSwitchFromH39ToForfaitProducesTwoPhasesMostRecentFirst(): void
|
||||
{
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2026-04-30'],
|
||||
['type' => ContractType::FORFAIT, 'hours' => 39, 'driver' => false, 'start' => '2026-05-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
|
||||
self::assertCount(2, $phases);
|
||||
self::assertSame(ContractType::FORFAIT, $phases[0]->contractType);
|
||||
self::assertTrue($phases[0]->isCurrent);
|
||||
self::assertSame(ContractType::H39, $phases[1]->contractType);
|
||||
self::assertFalse($phases[1]->isCurrent);
|
||||
self::assertSame('2026-04-30', $phases[1]->endDate?->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testInterimBetweenTwoH39PeriodsBreaksThePhases(): void
|
||||
{
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2020-06-01', 'end' => '2023-12-31'],
|
||||
['type' => ContractType::INTERIM, 'hours' => null, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-04-30'],
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2024-05-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
|
||||
self::assertCount(3, $phases);
|
||||
self::assertSame(ContractType::H39, $phases[0]->contractType);
|
||||
self::assertSame(ContractType::INTERIM, $phases[1]->contractType);
|
||||
self::assertSame(ContractType::H39, $phases[2]->contractType);
|
||||
}
|
||||
|
||||
public function testCustomPhasesSplitOnWeeklyHoursChange(): void
|
||||
{
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::CUSTOM, 'hours' => 28, 'driver' => false, 'start' => '2024-01-01', 'end' => '2024-12-31'],
|
||||
['type' => ContractType::CUSTOM, 'hours' => 30, 'driver' => false, 'start' => '2025-01-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
|
||||
self::assertCount(2, $phases);
|
||||
self::assertSame(30, $phases[0]->weeklyHours);
|
||||
self::assertSame(28, $phases[1]->weeklyHours);
|
||||
}
|
||||
|
||||
public function testPhasesSplitOnIsDriverChange(): void
|
||||
{
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2023-01-01', 'end' => '2024-12-31'],
|
||||
['type' => ContractType::H35, 'hours' => 35, 'driver' => true, 'start' => '2025-01-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
|
||||
self::assertCount(2, $phases);
|
||||
self::assertTrue($phases[0]->isDriver);
|
||||
self::assertFalse($phases[1]->isDriver);
|
||||
}
|
||||
|
||||
public function testPhasesEntirelyBeforeDataStartDateAreFilteredOut(): void
|
||||
{
|
||||
// H35 phase ends before 2026-02-23 → must be hidden; H39 phase spans the date → kept.
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2025-10-31'],
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2025-11-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver('2026-02-23')->resolvePhases($employee);
|
||||
|
||||
self::assertCount(1, $phases);
|
||||
self::assertSame(ContractType::H39, $phases[0]->contractType);
|
||||
}
|
||||
|
||||
public function testPhaseEndingExactlyOnDataStartDateIsKept(): void
|
||||
{
|
||||
// Edge case: a phase whose endDate equals the data start date is kept
|
||||
// (the inequality is `>= $dataStart`, not strict).
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2020-01-01', 'end' => '2026-02-23'],
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2026-02-24', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver('2026-02-23')->resolvePhases($employee);
|
||||
|
||||
self::assertCount(2, $phases);
|
||||
}
|
||||
|
||||
public function testNoFilteringWhenDataStartDateIsEmpty(): void
|
||||
{
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2020-12-31'],
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-01-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
|
||||
self::assertCount(2, $phases);
|
||||
}
|
||||
|
||||
public function testInvalidDataStartDateStringIsTreatedAsNull(): void
|
||||
{
|
||||
$employee = $this->buildEmployee([
|
||||
['type' => ContractType::H35, 'hours' => 35, 'driver' => false, 'start' => '2014-07-01', 'end' => '2020-12-31'],
|
||||
['type' => ContractType::H39, 'hours' => 39, 'driver' => false, 'start' => '2021-01-01', 'end' => null],
|
||||
]);
|
||||
|
||||
$phases = new EmployeeContractPhaseResolver('not-a-date')->resolvePhases($employee);
|
||||
|
||||
self::assertCount(2, $phases);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{type: ContractType, hours: ?int, driver: bool, start: string, end: ?string}> $periodsSpec
|
||||
*/
|
||||
private function buildEmployee(array $periodsSpec): Employee
|
||||
{
|
||||
$employee = new Employee();
|
||||
$id = 0;
|
||||
foreach ($periodsSpec as $spec) {
|
||||
$contract = new Contract();
|
||||
$contract->setName($spec['type']->value);
|
||||
$contract->setTrackingMode(
|
||||
ContractType::FORFAIT === $spec['type'] ? TrackingMode::PRESENCE->value : TrackingMode::TIME->value
|
||||
);
|
||||
$contract->setWeeklyHours($spec['hours']);
|
||||
|
||||
$period = new EmployeeContractPeriod();
|
||||
$reflection = new ReflectionProperty(EmployeeContractPeriod::class, 'id');
|
||||
$reflection->setValue($period, ++$id);
|
||||
$period->setEmployee($employee);
|
||||
$period->setContract($contract);
|
||||
$period->setStartDate(new DateTimeImmutable($spec['start']));
|
||||
$period->setEndDate(null !== $spec['end'] ? new DateTimeImmutable($spec['end']) : null);
|
||||
$period->setContractNature(ContractNature::CDI);
|
||||
$period->setIsDriver($spec['driver']);
|
||||
$employee->getContractPeriods()->add($period);
|
||||
}
|
||||
|
||||
return $employee;
|
||||
}
|
||||
}
|
||||
62
tests/Service/Exercise/ExerciseYearResolverTest.php
Normal file
62
tests/Service/Exercise/ExerciseYearResolverTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Exercise;
|
||||
|
||||
use App\Service\Exercise\ExerciseYearResolver;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ExerciseYearResolverTest extends TestCase
|
||||
{
|
||||
public function testNonForfaitJuneMapsToNextYear(): void
|
||||
{
|
||||
$resolver = new ExerciseYearResolver();
|
||||
|
||||
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-01')));
|
||||
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-30')));
|
||||
}
|
||||
|
||||
public function testNonForfaitMayMapsToSameYear(): void
|
||||
{
|
||||
$resolver = new ExerciseYearResolver();
|
||||
|
||||
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-05-01')));
|
||||
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-05-31')));
|
||||
}
|
||||
|
||||
public function testNonForfaitDecemberMapsToNextYear(): void
|
||||
{
|
||||
$resolver = new ExerciseYearResolver();
|
||||
|
||||
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-12-31')));
|
||||
}
|
||||
|
||||
public function testNonForfaitJanuaryMapsToSameYear(): void
|
||||
{
|
||||
$resolver = new ExerciseYearResolver();
|
||||
|
||||
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-01-15')));
|
||||
}
|
||||
|
||||
public function testForfaitReturnsCalendarYearRegardlessOfMonth(): void
|
||||
{
|
||||
$resolver = new ExerciseYearResolver();
|
||||
|
||||
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-01-15'), true));
|
||||
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-06-01'), true));
|
||||
self::assertSame(2025, $resolver->forDate(new DateTimeImmutable('2025-12-31'), true));
|
||||
}
|
||||
|
||||
public function testForfaitFlagDefaultsToFalse(): void
|
||||
{
|
||||
$resolver = new ExerciseYearResolver();
|
||||
|
||||
// June without explicit flag must follow non-forfait rule (year + 1).
|
||||
self::assertSame(2026, $resolver->forDate(new DateTimeImmutable('2025-06-01')));
|
||||
}
|
||||
}
|
||||
74
tests/Service/Rtt/RttRecoveryComputationServiceTest.php
Normal file
74
tests/Service/Rtt/RttRecoveryComputationServiceTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Rtt;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* The service constructor takes several final-class collaborators that PHPUnit cannot
|
||||
* double. Pure helpers are exercised via newInstanceWithoutConstructor + reflection.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class RttRecoveryComputationServiceTest extends TestCase
|
||||
{
|
||||
public function testResolveWeekAnchorDateReturnsFirstContractedDayWhenWeekStartsBeforeHire(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
$contract = new Contract();
|
||||
|
||||
$weekDays = ['2026-03-16', '2026-03-17', '2026-03-18', '2026-03-19', '2026-03-20', '2026-03-21', '2026-03-22'];
|
||||
$contractsByDate = [
|
||||
'2026-03-16' => null,
|
||||
'2026-03-17' => null,
|
||||
'2026-03-18' => null,
|
||||
'2026-03-19' => $contract,
|
||||
'2026-03-20' => $contract,
|
||||
'2026-03-21' => $contract,
|
||||
'2026-03-22' => $contract,
|
||||
];
|
||||
|
||||
$anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate);
|
||||
|
||||
self::assertSame('2026-03-19', $anchor);
|
||||
}
|
||||
|
||||
public function testResolveWeekAnchorDateReturnsFirstDayWhenItIsContracted(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
$contract = new Contract();
|
||||
|
||||
$weekDays = ['2026-03-23', '2026-03-24', '2026-03-25'];
|
||||
$contractsByDate = [
|
||||
'2026-03-23' => $contract,
|
||||
'2026-03-24' => $contract,
|
||||
'2026-03-25' => $contract,
|
||||
];
|
||||
|
||||
$anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate);
|
||||
|
||||
self::assertSame('2026-03-23', $anchor);
|
||||
}
|
||||
|
||||
public function testResolveWeekAnchorDateFallsBackToFirstDayWhenNoContract(): void
|
||||
{
|
||||
$service = new ReflectionClass(RttRecoveryComputationService::class)->newInstanceWithoutConstructor();
|
||||
|
||||
$weekDays = ['2026-03-16', '2026-03-17'];
|
||||
$contractsByDate = ['2026-03-16' => null, '2026-03-17' => null];
|
||||
|
||||
$anchor = $this->invokePrivate($service, 'resolveWeekAnchorDate', $weekDays, $contractsByDate);
|
||||
|
||||
self::assertSame('2026-03-16', $anchor);
|
||||
}
|
||||
|
||||
private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
|
||||
{
|
||||
return new ReflectionClass($obj::class)->getMethod($method)->invoke($obj, ...$args);
|
||||
}
|
||||
}
|
||||
99
tests/State/AbsencePrintProviderTest.php
Normal file
99
tests/State/AbsencePrintProviderTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\State\AbsencePrintProvider;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* The provider constructor takes final-class collaborators (Twig, repositories) that
|
||||
* PHPUnit cannot double. The pure contract-range helper is exercised via
|
||||
* newInstanceWithoutConstructor + reflection.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class AbsencePrintProviderTest extends TestCase
|
||||
{
|
||||
public function testHasContractInRangeFalseWhenContractEndedBeforeRange(): void
|
||||
{
|
||||
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
|
||||
$employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-04-30');
|
||||
|
||||
// Imprime mai : l'employé parti le 30/04 ne doit pas être inclus.
|
||||
self::assertFalse($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
|
||||
}
|
||||
|
||||
public function testHasContractInRangeTrueWhenContractOverlapsRange(): void
|
||||
{
|
||||
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
|
||||
$employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-05-15');
|
||||
|
||||
// Contrat finissant le 15/05 → chevauche le mois de mai → inclus.
|
||||
self::assertTrue($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
|
||||
}
|
||||
|
||||
public function testHasContractInRangeTrueForOpenEndedContract(): void
|
||||
{
|
||||
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
|
||||
$employee = $this->buildEmployeeWithPeriod('2020-01-01', null);
|
||||
|
||||
self::assertTrue($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
|
||||
}
|
||||
|
||||
public function testHasContractInRangeFalseWhenContractStartsAfterRange(): void
|
||||
{
|
||||
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
|
||||
$employee = $this->buildEmployeeWithPeriod('2026-06-01', null);
|
||||
|
||||
self::assertFalse($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
|
||||
}
|
||||
|
||||
public function testHasContractInRangeFalseWhenNoPeriods(): void
|
||||
{
|
||||
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
|
||||
$employee = new Employee();
|
||||
|
||||
self::assertFalse($this->hasInRange($provider, $employee, '2026-05-01', '2026-05-31'));
|
||||
}
|
||||
|
||||
public function testHasContractInRangeIncludesEmployeeEndingOnFromDayDespiteTimeComponent(): void
|
||||
{
|
||||
// Garde-fou : `from` portant une heure (cf. createFromFormat) ne doit pas exclure
|
||||
// un employé dont le contrat finit pile le jour de `from` (comparaison date seule).
|
||||
$provider = new ReflectionClass(AbsencePrintProvider::class)->newInstanceWithoutConstructor();
|
||||
$employee = $this->buildEmployeeWithPeriod('2025-01-01', '2026-05-01');
|
||||
|
||||
$result = new ReflectionClass($provider::class)
|
||||
->getMethod('hasContractInRange')
|
||||
->invoke($provider, $employee, new DateTimeImmutable('2026-05-01 08:33:53'), new DateTimeImmutable('2026-05-31 23:59:59'))
|
||||
;
|
||||
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
private function hasInRange(object $provider, Employee $employee, string $from, string $to): bool
|
||||
{
|
||||
return new ReflectionClass($provider::class)
|
||||
->getMethod('hasContractInRange')
|
||||
->invoke($provider, $employee, new DateTimeImmutable($from), new DateTimeImmutable($to))
|
||||
;
|
||||
}
|
||||
|
||||
private function buildEmployeeWithPeriod(string $start, ?string $end): Employee
|
||||
{
|
||||
$employee = new Employee();
|
||||
$period = new EmployeeContractPeriod();
|
||||
$period->setEmployee($employee);
|
||||
$period->setStartDate(new DateTimeImmutable($start));
|
||||
$period->setEndDate(null !== $end ? new DateTimeImmutable($end) : null);
|
||||
$employee->getContractPeriods()->add($period);
|
||||
|
||||
return $employee;
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\Dto\Contracts\ContractPhase;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use App\Service\Exercise\ExerciseYearResolver;
|
||||
use App\State\EmployeeLeaveSummaryProvider;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EmployeeLeaveSummaryProviderTest extends TestCase
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Existing tests (unchanged) — verify accrual prorating arithmetic.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
@@ -68,4 +85,521 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase
|
||||
|
||||
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
||||
}
|
||||
|
||||
public function testComputeForfaitWorkTargetDaysEntryYearProrates(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// Grégory : 168 jours ouvrés sur la période − 12.94 congés acquis (repos + CP reportés) ≈ 155.06
|
||||
$result = $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 168, true, 12.94);
|
||||
|
||||
self::assertEqualsWithDelta(155.06, $result, 0.001);
|
||||
}
|
||||
|
||||
public function testComputeForfaitWorkTargetDaysFullYearIs218IgnoringBonusAndFractioned(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// Année pleine : la cible reste 218 quelle que soit la valeur des congés acquis
|
||||
// (les bonus week-end/férié et jours fractionnés ne réduisent pas la cible).
|
||||
self::assertSame(218.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 252, false, 34.0));
|
||||
self::assertSame(218.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 252, false, 40.0));
|
||||
}
|
||||
|
||||
public function testComputeForfaitWorkTargetDaysFullYearCapsAtBusinessDaysWhenFewer(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// Période avec moins de 218 jours ouvrés (phase forfait clôturée en cours d'année) → cap aux jours ouvrés.
|
||||
self::assertSame(200.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 200, false, 5.0));
|
||||
}
|
||||
|
||||
public function testComputeProratedForfaitRepoDaysGregoryCase(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// 2026 : 252 jours ouvrés/an, 168 sur la période 01/05→31/12.
|
||||
// repos année = 252 - 218 - 25 = 9 ; proratisé = 9 × 168/252 = 6.0
|
||||
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 168);
|
||||
|
||||
self::assertEqualsWithDelta(6.0, $result, 0.001);
|
||||
}
|
||||
|
||||
public function testComputeProratedForfaitRepoDaysFullYearEquals9(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// Année pleine : 9 × 252/252 = 9.0
|
||||
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 252);
|
||||
|
||||
self::assertEqualsWithDelta(9.0, $result, 0.001);
|
||||
}
|
||||
|
||||
public function testComputeProratedForfaitRepoDaysClampsNegativeToZero(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
// Année avec trop peu de jours ouvrés (240 - 218 - 25 < 0) → 0
|
||||
$result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 240, 160);
|
||||
|
||||
self::assertSame(0.0, $result);
|
||||
}
|
||||
|
||||
public function testComputeProratedForfaitRepoDaysZeroYearGuard(): void
|
||||
{
|
||||
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||
|
||||
self::assertSame(0.0, $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 0, 0));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase resolution tests (Task 3 — phaseId support).
|
||||
// The repository / service dependencies are typed against final classes
|
||||
// which PHPUnit cannot double, so phase resolution is exercised via
|
||||
// reflection on private methods to avoid instantiating the full DI graph.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void
|
||||
{
|
||||
$employee = $this->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::assertSame(ContractType::FORFAIT, $resolved->contractType);
|
||||
self::assertTrue($resolved->isCurrent);
|
||||
}
|
||||
|
||||
public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void
|
||||
{
|
||||
// Verifies resolveLeavePolicy uses the phase's contractType (not the current contract).
|
||||
$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]);
|
||||
$from = new DateTimeImmutable('2025-06-01');
|
||||
$to = new DateTimeImmutable('2026-04-30');
|
||||
$leavePolicy = $this->invokePrivate($provider, 'resolveLeavePolicy', $employee, $h39Phase, $from, $to);
|
||||
|
||||
self::assertNotNull($leavePolicy);
|
||||
self::assertSame('CDI_CDD_NON_FORFAIT', $leavePolicy['ruleCode']);
|
||||
self::assertSame(25.0, $leavePolicy['acquiredDays']);
|
||||
self::assertEqualsWithDelta(25.0 / 12.0, $leavePolicy['accrualPerMonth'], 0.0001);
|
||||
}
|
||||
|
||||
public function testResolvePeriodBoundsCapsAtPhaseEndDate(): void
|
||||
{
|
||||
// 39h phase (June 2020 → April 30 2026). Exercise 2026 spans June 2025 → May 31 2026.
|
||||
// The phase cap should clip the upper bound to April 30 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' => '2026']);
|
||||
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
|
||||
|
||||
self::assertSame('2025-06-01', $from->format('Y-m-d'));
|
||||
self::assertSame('2026-04-30', $to->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testTransitionExerciseOnH39PhaseAccruesAround22Point9Days(): void
|
||||
{
|
||||
// 11 full months of accrual at 25/12 ≈ 22.917 days.
|
||||
$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']);
|
||||
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
|
||||
|
||||
// Period bounds for exercise 2026 on H39 phase = June 1 2025 → April 30 2026.
|
||||
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
|
||||
$acquired = $method->invoke($provider, 25.0, 25.0 / 12.0, $from, $to);
|
||||
|
||||
self::assertEqualsWithDelta(22.92, $acquired, 0.1);
|
||||
}
|
||||
|
||||
public function testIsForfaitEntryYearTrueOnStartYear(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
self::assertTrue($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
|
||||
}
|
||||
|
||||
public function testIsForfaitEntryYearFalseOnSubsequentFullYear(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2027));
|
||||
}
|
||||
|
||||
public function testIsForfaitEntryYearFalseWhenForfaitStartsJan1(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2025-12-31', '2026-01-01');
|
||||
$forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
// Forfait démarrant un 1er janvier = année pleine, pas une entrée en cours d'année.
|
||||
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
|
||||
}
|
||||
|
||||
public function testIsForfaitEntryYearFalseForNonForfaitPhase(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$h39Phase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[1];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $h39Phase, 2026));
|
||||
}
|
||||
|
||||
public function testResolvePhaseImmediatelyBeforeReturnsPriorH39Phase(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
$forfaitPhase = $phases[0]; // current FORFAIT
|
||||
$h39Phase = $phases[1];
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
$prior = $this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $forfaitPhase);
|
||||
|
||||
self::assertNotNull($prior);
|
||||
self::assertSame($h39Phase->id, $prior->id);
|
||||
self::assertSame(ContractType::H39, $prior->contractType);
|
||||
}
|
||||
|
||||
public function testResolvePhaseImmediatelyBeforeReturnsNullForFirstPhase(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
$firstPhase = $phases[1]; // the H39 (earliest)
|
||||
$provider = $this->buildProvider();
|
||||
|
||||
self::assertNull($this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $firstPhase));
|
||||
}
|
||||
|
||||
public function testNonForfaitPhaseStartingMidExerciseUsesFullExerciseFromAsStart(): void
|
||||
{
|
||||
// Scenario: 35h CDI from 2014-07-01 to 2025-10-31, then 39h CDI from 2025-11-01.
|
||||
// Both phases are non-forfait (same leave rule CDI_CDD_NON_FORFAIT).
|
||||
// Viewing exercise 2026 on the current 39h phase, accrual must run from the
|
||||
// exercise start (June 1, 2025), NOT from the phase start (November 1, 2025).
|
||||
// Otherwise the 5 months of June-October under 35h would be lost from the
|
||||
// annual CP accrual, which is wrong (CP exercise is annual, not per-phase).
|
||||
$employee = $this->buildH35ToH39Transition('2014-07-01', '2025-10-31', '2025-11-01');
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
$h39Phase = $phases[0]; // current
|
||||
|
||||
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
|
||||
|
||||
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
|
||||
|
||||
self::assertSame('2025-06-01', $from->format('Y-m-d'));
|
||||
self::assertSame('2026-05-31', $to->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function testForfaitPhaseStartingMidYearCapsFromAtPhaseStart(): void
|
||||
{
|
||||
// Scenario: 39h CDI ends 2026-04-30, FORFAIT from 2026-05-01.
|
||||
// Viewing year 2026 on the FORFAIT phase, the period must be capped at
|
||||
// phase start (May 1) so that only the FORFAIT portion of the calendar
|
||||
// year is counted.
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
$forfaitPhase = $phases[0]; // current FORFAIT
|
||||
|
||||
$provider = $this->buildProvider(['phaseId' => (string) $forfaitPhase->id, 'year' => '2026']);
|
||||
|
||||
[$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $forfaitPhase);
|
||||
|
||||
self::assertSame('2026-05-01', $from->format('Y-m-d'));
|
||||
self::assertSame('2026-12-31', $to->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', $employee, $h39Phase);
|
||||
|
||||
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 (non-forfait) = 2021 (since month >=6 = year+1).
|
||||
$provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']);
|
||||
$year = $this->invokePrivate($provider, 'resolveYear', $employee, $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 → non-forfait 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', $employee, $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', $employee, $currentPhase);
|
||||
|
||||
// Today is 2026-05-19, FORFAIT phase → year is the current calendar year (2026).
|
||||
self::assertSame(2026, $year);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regression: terminated-employee path through `computeYearSummary` without
|
||||
// an explicit phase (legacy callers: LeaveRecapRowBuilder,
|
||||
// DumpVerificationSnapshotCommand). Before the phase-aware refactor, the
|
||||
// period bounds were NOT capped at the contract end for terminated
|
||||
// employees (because Employee::getCurrentContractEndDate() returns null
|
||||
// when no period covers "today"). The new code resolves a fallback phase
|
||||
// whose `isCurrent` is false, which would otherwise cap `to` at the phase
|
||||
// end — a behavior change for legacy callers. The flag `applyPhaseEndCap`
|
||||
// toggles this cap so legacy callers get the pre-refactor behavior.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function testTerminatedEmployeeWithoutExplicitPhaseSkipsPhaseEndCap(): void
|
||||
{
|
||||
// Terminated employee: H39 phase ending 2024-12-31 (well in the past).
|
||||
$employee = $this->buildTerminatedEmployee('2020-06-01', '2024-12-31');
|
||||
$phases = new EmployeeContractPhaseResolver()->resolvePhases($employee);
|
||||
self::assertCount(1, $phases);
|
||||
$phase = $phases[0];
|
||||
self::assertFalse($phase->isCurrent, 'Sanity: terminated phase must not be flagged as current.');
|
||||
|
||||
$provider = $this->buildProvider([]);
|
||||
|
||||
// applyPhaseEndCap=false → mimics legacy callers (no explicit phase):
|
||||
// the upper bound MUST stay at the natural leave-year end (May 31).
|
||||
[$fromLegacy, $toLegacy] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, false);
|
||||
self::assertSame('2024-06-01', $fromLegacy->format('Y-m-d'));
|
||||
self::assertSame('2025-05-31', $toLegacy->format('Y-m-d'));
|
||||
|
||||
// applyPhaseEndCap=true → explicit-phase callers get the cap at phase end.
|
||||
[$fromCap, $toCap] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, true);
|
||||
self::assertSame('2024-06-01', $fromCap->format('Y-m-d'));
|
||||
self::assertSame('2024-12-31', $toCap->format('Y-m-d'));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test harness helpers.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a terminated-employee fixture: a single H39 period ending before today.
|
||||
*/
|
||||
private function buildTerminatedEmployee(string $start, string $end): Employee
|
||||
{
|
||||
$employee = new Employee();
|
||||
$this->setEntityId($employee, 2);
|
||||
|
||||
$contract = new Contract();
|
||||
$contract->setName('39H');
|
||||
$contract->setTrackingMode(TrackingMode::TIME->value);
|
||||
$contract->setWeeklyHours(39);
|
||||
|
||||
$period = new EmployeeContractPeriod();
|
||||
$this->setEntityId($period, 10);
|
||||
$period->setEmployee($employee);
|
||||
$period->setContract($contract);
|
||||
$period->setStartDate(new DateTimeImmutable($start));
|
||||
$period->setEndDate(new DateTimeImmutable($end));
|
||||
$period->setContractNature(ContractNature::CDI);
|
||||
$period->setIsDriver(false);
|
||||
|
||||
$employee->getContractPeriods()->add($period);
|
||||
|
||||
return $employee;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a two-period employee transitioning from H39 to FORFAIT.
|
||||
*/
|
||||
private function buildH35ToH39Transition(string $h35Start, string $h35End, string $h39Start): Employee
|
||||
{
|
||||
$employee = new Employee();
|
||||
$this->setEntityId($employee, 1);
|
||||
|
||||
$h35Contract = new Contract();
|
||||
$h35Contract->setName('35H');
|
||||
$h35Contract->setTrackingMode(TrackingMode::TIME->value);
|
||||
$h35Contract->setWeeklyHours(35);
|
||||
|
||||
$h39Contract = new Contract();
|
||||
$h39Contract->setName('39H');
|
||||
$h39Contract->setTrackingMode(TrackingMode::TIME->value);
|
||||
$h39Contract->setWeeklyHours(39);
|
||||
|
||||
$h35Period = new EmployeeContractPeriod();
|
||||
$this->setEntityId($h35Period, 1);
|
||||
$h35Period->setEmployee($employee);
|
||||
$h35Period->setContract($h35Contract);
|
||||
$h35Period->setStartDate(new DateTimeImmutable($h35Start));
|
||||
$h35Period->setEndDate(new DateTimeImmutable($h35End));
|
||||
$h35Period->setContractNature(ContractNature::CDI);
|
||||
$h35Period->setIsDriver(false);
|
||||
|
||||
$h39Period = new EmployeeContractPeriod();
|
||||
$this->setEntityId($h39Period, 2);
|
||||
$h39Period->setEmployee($employee);
|
||||
$h39Period->setContract($h39Contract);
|
||||
$h39Period->setStartDate(new DateTimeImmutable($h39Start));
|
||||
$h39Period->setEndDate(null);
|
||||
$h39Period->setContractNature(ContractNature::CDI);
|
||||
$h39Period->setIsDriver(false);
|
||||
|
||||
$employee->getContractPeriods()->add($h35Period);
|
||||
$employee->getContractPeriods()->add($h39Period);
|
||||
|
||||
return $employee;
|
||||
}
|
||||
|
||||
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, LeaveBalanceComputationService, 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`. Tests targeting heavier code paths exercise
|
||||
* private methods directly (resolveTargetPhase, resolvePeriodBounds, etc.).
|
||||
*
|
||||
* @param array<string, string> $request query parameters (year, phaseId, ...)
|
||||
*/
|
||||
private function buildProvider(array $request = []): EmployeeLeaveSummaryProvider
|
||||
{
|
||||
$requestStack = new RequestStack();
|
||||
$requestStack->push(new Request(query: $request));
|
||||
|
||||
$reflection = new ReflectionClass(EmployeeLeaveSummaryProvider::class);
|
||||
$provider = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$this->setReadonlyProperty($provider, 'requestStack', $requestStack);
|
||||
$this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
|
||||
$this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver());
|
||||
$this->setReadonlyProperty($provider, 'dataStartDate', 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);
|
||||
}
|
||||
}
|
||||
|
||||
169
tests/State/EmployeeRttPaymentProcessorTest.php
Normal file
169
tests/State/EmployeeRttPaymentProcessorTest.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use App\Service\Exercise\ExerciseYearResolver;
|
||||
use App\State\EmployeeRttPaymentProcessor;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* Exercises the year-acceptance guard of EmployeeRttPaymentProcessor.
|
||||
*
|
||||
* The processor depends on final repositories (EmployeeRepository,
|
||||
* EmployeeRttPaymentRepository) which PHPUnit cannot double. The guard logic
|
||||
* lives in a private helper (assertYearAllowedForPayment) tested directly via
|
||||
* reflection — same pattern used in EmployeeRttSummaryProviderTest.
|
||||
*/
|
||||
final class EmployeeRttPaymentProcessorTest extends TestCase
|
||||
{
|
||||
public function testPaymentAllowedOnCurrentExercise(): void
|
||||
{
|
||||
// Today = 2026-05-19 (env clock) → current exercise = 2026.
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||
|
||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026);
|
||||
|
||||
// No exception → guard accepts current exercise.
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testPaymentAllowedOnLastExerciseOfClosedPhase(): void
|
||||
{
|
||||
// Phase 39h closed 2026-04-30, FORFAIT from 2026-05-01.
|
||||
// Exercise 2026 (Juin 2025 → Mai 2026) contains the H39 phase end date.
|
||||
// Payment must be allowed on exercise 2026 even when current exercise is 2027.
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
|
||||
|
||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2026);
|
||||
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testPaymentRejectedOnEarlierExerciseOfClosedPhase(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2027-01-15'));
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2024);
|
||||
}
|
||||
|
||||
public function testPaymentRejectedOnFutureExercise(): void
|
||||
{
|
||||
$employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
|
||||
$processor = $this->buildProcessorWithClock(new DateTimeImmutable('2026-05-19'));
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->invokePrivate($processor, 'assertYearAllowedForPayment', $employee, 2030);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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 processor with a fixed clock. The repositories are
|
||||
* declared on final classes that PHPUnit cannot double, so we bypass full
|
||||
* instantiation via newInstanceWithoutConstructor and only seed the
|
||||
* properties the tested private guard reads: phaseResolver + clock.
|
||||
*/
|
||||
private function buildProcessorWithClock(DateTimeImmutable $today): EmployeeRttPaymentProcessor
|
||||
{
|
||||
$reflection = new ReflectionClass(EmployeeRttPaymentProcessor::class);
|
||||
$processor = $reflection->newInstanceWithoutConstructor();
|
||||
|
||||
$clock = new readonly class($today) implements ClockInterface {
|
||||
public function __construct(private DateTimeImmutable $now) {}
|
||||
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return $this->now;
|
||||
}
|
||||
};
|
||||
|
||||
$this->setReadonlyProperty($processor, 'phaseResolver', new EmployeeContractPhaseResolver());
|
||||
$this->setReadonlyProperty($processor, 'clock', $clock);
|
||||
$this->setReadonlyProperty($processor, 'exerciseYearResolver', new ExerciseYearResolver());
|
||||
|
||||
return $processor;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
293
tests/State/EmployeeRttSummaryProviderTest.php
Normal file
293
tests/State/EmployeeRttSummaryProviderTest.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use App\Dto\Contracts\ContractPhase;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||
use App\Service\Exercise\ExerciseYearResolver;
|
||||
use App\State\EmployeeRttSummaryProvider;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class EmployeeRttSummaryProviderTest extends TestCase
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase resolution tests (Task 4 — phaseId support).
|
||||
// The repository / service dependencies are typed against final classes
|
||||
// which PHPUnit cannot double, so phase resolution is exercised via
|
||||
// reflection on private methods to avoid instantiating the full DI graph.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void
|
||||
{
|
||||
$employee = $this->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<string, string> $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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user