feat(leave) : phaseId support in EmployeeLeaveSummaryProvider

The provider now resolves a target ContractPhase from a ?phaseId query
parameter and propagates it through resolveYear, resolvePeriodBounds,
resolveLeavePolicy, resolveAccrualCalculationEndDate,
resolveTakenCalculationEndDate, and resolveFirstComputationYear.

- phaseId missing → current phase (legacy behavior preserved)
- phaseId valid → past/current phase used for rule code, weekly hours
  and period bounds (capped at phase end)
- phaseId invalid (non-numeric or unknown) → 422
- year missing + phaseId → year derived from phase end date
- year out of phase range + phaseId → silent clamp to phase boundaries

Public methods computeYearSummary/resolvePaidLeaveDays/resolveLeaveYearForToday
remain backward-compatible for external callers (LeaveRecapRowBuilder,
DumpVerificationSnapshotCommand).
This commit is contained in:
2026-05-19 10:52:56 +02:00
parent e7035a7c30
commit 9efe0e81a0
2 changed files with 477 additions and 57 deletions

View File

@@ -4,16 +4,32 @@ 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\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 +84,253 @@ final class EmployeeLeaveSummaryProviderTest extends TestCase
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
}
// -----------------------------------------------------------------------
// 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 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);
}
// -----------------------------------------------------------------------
// 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, 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, '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);
}
}