From d5230f685ed4df281131786e9bbc36edb1cb4aa0 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 19 Feb 2026 22:06:54 +0100 Subject: [PATCH] feat : ajout des TU --- .idea/SIRH.iml | 7 + config/services.yaml | 4 + src/Repository/AbsenceRepository.php | 3 +- .../AbsenceReadRepositoryInterface.php | 32 ++++ .../EmployeeScopedRepositoryInterface.php | 16 ++ .../WorkHourReadRepositoryInterface.php | 22 +++ src/Repository/EmployeeRepository.php | 7 +- src/Repository/WorkHourRepository.php | 3 +- src/State/AbsenceWriteProcessor.php | 8 +- src/State/WorkHourDayContextProvider.php | 8 +- src/State/WorkHourWeeklySummaryProvider.php | 12 +- .../WorkHours/AbsenceSegmentsResolverTest.php | 61 +++++++ .../WorkHours/WorkedHoursCreditPolicyTest.php | 84 ++++++++++ tests/State/AbsenceWriteProcessorTest.php | 126 ++++++++++++++ .../State/WorkHourDayContextProviderTest.php | 154 +++++++++++++++++ .../WorkHourWeeklySummaryProviderTest.php | 156 ++++++++++++++++++ 16 files changed, 684 insertions(+), 19 deletions(-) create mode 100644 src/Repository/Contract/AbsenceReadRepositoryInterface.php create mode 100644 src/Repository/Contract/EmployeeScopedRepositoryInterface.php create mode 100644 src/Repository/Contract/WorkHourReadRepositoryInterface.php create mode 100644 tests/Service/WorkHours/AbsenceSegmentsResolverTest.php create mode 100644 tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php create mode 100644 tests/State/AbsenceWriteProcessorTest.php create mode 100644 tests/State/WorkHourDayContextProviderTest.php create mode 100644 tests/State/WorkHourWeeklySummaryProviderTest.php diff --git a/.idea/SIRH.iml b/.idea/SIRH.iml index 6ff23a1..c7476c0 100644 --- a/.idea/SIRH.iml +++ b/.idea/SIRH.iml @@ -145,6 +145,13 @@ + + + + + + + diff --git a/config/services.yaml b/config/services.yaml index 49aba49..9a7408a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -22,5 +22,9 @@ services: App\: resource: '../src/' + App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository' + App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository' + App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository' + # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/src/Repository/AbsenceRepository.php b/src/Repository/AbsenceRepository.php index c64063d..397e6bb 100644 --- a/src/Repository/AbsenceRepository.php +++ b/src/Repository/AbsenceRepository.php @@ -6,6 +6,7 @@ namespace App\Repository; use App\Entity\Absence; use App\Entity\Employee; +use App\Repository\Contract\AbsenceReadRepositoryInterface; use DateTimeImmutable; use DateTimeInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; @@ -14,7 +15,7 @@ use Doctrine\Persistence\ManagerRegistry; /** * @extends ServiceEntityRepository */ -final class AbsenceRepository extends ServiceEntityRepository +final class AbsenceRepository extends ServiceEntityRepository implements AbsenceReadRepositoryInterface { public function __construct(ManagerRegistry $registry) { diff --git a/src/Repository/Contract/AbsenceReadRepositoryInterface.php b/src/Repository/Contract/AbsenceReadRepositoryInterface.php new file mode 100644 index 0000000..5b7bed3 --- /dev/null +++ b/src/Repository/Contract/AbsenceReadRepositoryInterface.php @@ -0,0 +1,32 @@ + $employees + * + * @return list + */ + public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array; + + /** + * @param list $employees + * + * @return list + */ + public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array; + + /** + * @return list + */ + public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array; +} diff --git a/src/Repository/Contract/EmployeeScopedRepositoryInterface.php b/src/Repository/Contract/EmployeeScopedRepositoryInterface.php new file mode 100644 index 0000000..e9bb07c --- /dev/null +++ b/src/Repository/Contract/EmployeeScopedRepositoryInterface.php @@ -0,0 +1,16 @@ + + */ + public function findScoped(User $user): array; +} diff --git a/src/Repository/Contract/WorkHourReadRepositoryInterface.php b/src/Repository/Contract/WorkHourReadRepositoryInterface.php new file mode 100644 index 0000000..bec3c12 --- /dev/null +++ b/src/Repository/Contract/WorkHourReadRepositoryInterface.php @@ -0,0 +1,22 @@ + $employees + * + * @return list + */ + public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array; + + public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool; +} diff --git a/src/Repository/EmployeeRepository.php b/src/Repository/EmployeeRepository.php index f96c893..dd085ab 100644 --- a/src/Repository/EmployeeRepository.php +++ b/src/Repository/EmployeeRepository.php @@ -6,6 +6,7 @@ namespace App\Repository; use App\Entity\Employee; use App\Entity\User; +use App\Repository\Contract\EmployeeScopedRepositoryInterface; use App\Security\EmployeeScopeService; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; @@ -13,7 +14,7 @@ use Doctrine\Persistence\ManagerRegistry; /** * @extends ServiceEntityRepository */ -final class EmployeeRepository extends ServiceEntityRepository +final class EmployeeRepository extends ServiceEntityRepository implements EmployeeScopedRepositoryInterface { public function __construct( ManagerRegistry $registry, @@ -70,7 +71,7 @@ final class EmployeeRepository extends ServiceEntityRepository $this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_scoped_list', $user); - /** @var list $employees */ + // @var list $employees return $qb->getQuery()->getResult(); } @@ -97,7 +98,7 @@ final class EmployeeRepository extends ServiceEntityRepository ; } - /** @var list $employees */ + // @var list $employees return $qb->getQuery()->getResult(); } } diff --git a/src/Repository/WorkHourRepository.php b/src/Repository/WorkHourRepository.php index 23b1d4a..daed9bd 100644 --- a/src/Repository/WorkHourRepository.php +++ b/src/Repository/WorkHourRepository.php @@ -6,6 +6,7 @@ namespace App\Repository; use App\Entity\Employee; use App\Entity\WorkHour; +use App\Repository\Contract\WorkHourReadRepositoryInterface; use DateTimeImmutable; use DateTimeInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; @@ -14,7 +15,7 @@ use Doctrine\Persistence\ManagerRegistry; /** * @extends ServiceEntityRepository */ -final class WorkHourRepository extends ServiceEntityRepository +final class WorkHourRepository extends ServiceEntityRepository implements WorkHourReadRepositoryInterface { public function __construct(ManagerRegistry $registry) { diff --git a/src/State/AbsenceWriteProcessor.php b/src/State/AbsenceWriteProcessor.php index d8f4b8d..2812d04 100644 --- a/src/State/AbsenceWriteProcessor.php +++ b/src/State/AbsenceWriteProcessor.php @@ -9,8 +9,8 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\Absence; use App\Enum\HalfDay; -use App\Repository\AbsenceRepository; -use App\Repository\WorkHourRepository; +use App\Repository\Contract\AbsenceReadRepositoryInterface; +use App\Repository\Contract\WorkHourReadRepositoryInterface; use DateInterval; use DatePeriod; use DateTime; @@ -23,8 +23,8 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface { public function __construct( private EntityManagerInterface $entityManager, - private AbsenceRepository $absenceRepository, - private WorkHourRepository $workHourRepository, + private AbsenceReadRepositoryInterface $absenceRepository, + private WorkHourReadRepositoryInterface $workHourRepository, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed diff --git a/src/State/WorkHourDayContextProvider.php b/src/State/WorkHourDayContextProvider.php index a410915..4a03ac8 100644 --- a/src/State/WorkHourDayContextProvider.php +++ b/src/State/WorkHourDayContextProvider.php @@ -9,8 +9,8 @@ use ApiPlatform\State\ProviderInterface; use App\ApiResource\WorkHourDayContext; use App\Dto\WorkHours\DayContextRow; use App\Entity\User; -use App\Repository\AbsenceRepository; -use App\Repository\EmployeeRepository; +use App\Repository\Contract\AbsenceReadRepositoryInterface; +use App\Repository\Contract\EmployeeScopedRepositoryInterface; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\WorkedHoursCreditPolicy; use DateTimeImmutable; @@ -24,8 +24,8 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface public function __construct( private Security $security, private RequestStack $requestStack, - private EmployeeRepository $employeeRepository, - private AbsenceRepository $absenceRepository, + private EmployeeScopedRepositoryInterface $employeeRepository, + private AbsenceReadRepositoryInterface $absenceRepository, private AbsenceSegmentsResolver $absenceSegmentsResolver, private WorkedHoursCreditPolicy $workedHoursCreditPolicy, ) {} diff --git a/src/State/WorkHourWeeklySummaryProvider.php b/src/State/WorkHourWeeklySummaryProvider.php index f4349b4..e20d622 100644 --- a/src/State/WorkHourWeeklySummaryProvider.php +++ b/src/State/WorkHourWeeklySummaryProvider.php @@ -12,9 +12,9 @@ use App\Entity\Absence; use App\Entity\Employee; use App\Entity\User; use App\Entity\WorkHour; -use App\Repository\AbsenceRepository; -use App\Repository\EmployeeRepository; -use App\Repository\WorkHourRepository; +use App\Repository\Contract\AbsenceReadRepositoryInterface; +use App\Repository\Contract\EmployeeScopedRepositoryInterface; +use App\Repository\Contract\WorkHourReadRepositoryInterface; use App\Service\WorkHours\AbsenceSegmentsResolver; use App\Service\WorkHours\WorkedHoursCreditPolicy; use DateTimeImmutable; @@ -28,9 +28,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface public function __construct( private Security $security, private RequestStack $requestStack, - private EmployeeRepository $employeeRepository, - private WorkHourRepository $workHourRepository, - private AbsenceRepository $absenceRepository, + private EmployeeScopedRepositoryInterface $employeeRepository, + private WorkHourReadRepositoryInterface $workHourRepository, + private AbsenceReadRepositoryInterface $absenceRepository, private AbsenceSegmentsResolver $absenceSegmentsResolver, private WorkedHoursCreditPolicy $workedHoursCreditPolicy, ) {} diff --git a/tests/Service/WorkHours/AbsenceSegmentsResolverTest.php b/tests/Service/WorkHours/AbsenceSegmentsResolverTest.php new file mode 100644 index 0000000..28b2554 --- /dev/null +++ b/tests/Service/WorkHours/AbsenceSegmentsResolverTest.php @@ -0,0 +1,61 @@ +setStartDate(new DateTime('2026-02-16')) + ->setEndDate(new DateTime('2026-02-16')) + ->setStartHalf(HalfDay::AM) + ->setEndHalf(HalfDay::AM) + ; + + $resolver = new AbsenceSegmentsResolver(); + + self::assertSame([true, false], $resolver->resolveForDate($absence, '2026-02-16')); + } + + public function testResolveForSameDayAfternoonOnly(): void + { + $absence = new Absence() + ->setStartDate(new DateTime('2026-02-16')) + ->setEndDate(new DateTime('2026-02-16')) + ->setStartHalf(HalfDay::PM) + ->setEndHalf(HalfDay::PM) + ; + + $resolver = new AbsenceSegmentsResolver(); + + self::assertSame([false, true], $resolver->resolveForDate($absence, '2026-02-16')); + } + + public function testResolveForMultiDayBoundaries(): void + { + $absence = new Absence() + ->setStartDate(new DateTime('2026-02-16')) + ->setEndDate(new DateTime('2026-02-18')) + ->setStartHalf(HalfDay::PM) + ->setEndHalf(HalfDay::AM) + ; + + $resolver = new AbsenceSegmentsResolver(); + + self::assertSame([false, true], $resolver->resolveForDate($absence, '2026-02-16')); + self::assertSame([true, true], $resolver->resolveForDate($absence, '2026-02-17')); + self::assertSame([true, false], $resolver->resolveForDate($absence, '2026-02-18')); + } +} diff --git a/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php b/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php new file mode 100644 index 0000000..4ffd188 --- /dev/null +++ b/tests/Service/WorkHours/WorkedHoursCreditPolicyTest.php @@ -0,0 +1,84 @@ +buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true); + + $minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false); + + self::assertSame(210, $minutes); + } + + public function testComputeCreditedMinutesFor4hContractFullDay(): void + { + $policy = new WorkedHoursCreditPolicy(); + $absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true); + + $minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true); + + self::assertSame(120, $minutes); + } + + public function testComputeCreditedPresenceUnitsForPresenceContract(): void + { + $policy = new WorkedHoursCreditPolicy(); + $absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true); + + $units = $policy->computeCreditedPresenceUnits($absence, true, false); + + self::assertSame(0.5, $units); + } + + public function testNoCreditWhenAbsenceTypeDoesNotCount(): void + { + $policy = new WorkedHoursCreditPolicy(); + $absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false); + + self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true)); + self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, true, true)); + } + + private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence + { + $contract = new Contract() + ->setName('Contrat test') + ->setTrackingMode($trackMode) + ->setWeeklyHours($weeklyHours) + ; + $employee = new Employee() + ->setFirstName('Alice') + ->setLastName('Durand') + ->setContract($contract) + ; + $type = new AbsenceType() + ->setCode('CP') + ->setLabel('Congés') + ->setColor('#000') + ->setCountAsWorkedHours($countAsWorked) + ; + + return new Absence() + ->setEmployee($employee) + ->setType($type) + ->setStartDate(new DateTime('2026-02-16')) + ->setEndDate(new DateTime('2026-02-16')); + } +} diff --git a/tests/State/AbsenceWriteProcessorTest.php b/tests/State/AbsenceWriteProcessorTest.php new file mode 100644 index 0000000..7494a81 --- /dev/null +++ b/tests/State/AbsenceWriteProcessorTest.php @@ -0,0 +1,126 @@ +createMock(EntityManagerInterface::class); + $absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class); + $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository); + + $absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM); + + $workHourRepository->expects(self::once()) + ->method('hasValidatedInRange') + ->willReturn(false) + ; + $absenceRepository->expects(self::once()) + ->method('findByEmployeeAndDateRange') + ->willReturn([]) + ; + $entityManager->expects(self::exactly(3))->method('persist'); + $entityManager->expects(self::once())->method('flush'); + + $result = $this->processor->process($absence, new Post()); + + self::assertSame($absence, $result); + self::assertSame('2026-02-16', $absence->getStartDate()->format('Y-m-d')); + self::assertSame('2026-02-16', $absence->getEndDate()->format('Y-m-d')); + } + + public function testDeleteThrowsWhenValidated(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); + $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository); + + $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); + + $workHourRepository->expects(self::once()) + ->method('hasValidatedInRange') + ->willReturn(true) + ; + $entityManager->expects(self::never())->method('remove'); + $entityManager->expects(self::never())->method('flush'); + + $this->expectException(ConflictHttpException::class); + $this->processor->process($absence, new Delete()); + } + + public function testDeleteRemovesWhenNotValidated(): void + { + $entityManager = $this->createMock(EntityManagerInterface::class); + $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); + $workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository); + + $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM); + + $workHourRepository->expects(self::once()) + ->method('hasValidatedInRange') + ->willReturn(false) + ; + $entityManager->expects(self::once())->method('remove')->with($absence); + $entityManager->expects(self::once())->method('flush'); + + $result = $this->processor->process($absence, new Delete()); + + self::assertNull($result); + } + + public function testPostThrowsOnInvalidHalfDayOrder(): void + { + $entityManager = $this->createStub(EntityManagerInterface::class); + $absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); + $workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class); + $this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository); + + $absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM); + + $this->expectException(UnprocessableEntityHttpException::class); + $this->processor->process($absence, new Post()); + } + + private function buildAbsence(string $startDate, string $endDate, HalfDay $startHalf, HalfDay $endHalf): Absence + { + $contract = new Contract()->setName('35h')->setTrackingMode(Contract::TRACKING_TIME)->setWeeklyHours(35); + $employee = new Employee()->setFirstName('Test')->setLastName('User')->setContract($contract); + $type = new AbsenceType()->setCode('CP')->setLabel('Congé')->setColor('#000')->setCountAsWorkedHours(true); + + return new Absence() + ->setEmployee($employee) + ->setType($type) + ->setComment('x') + ->setStartDate(new DateTime($startDate)) + ->setEndDate(new DateTime($endDate)) + ->setStartHalf($startHalf) + ->setEndHalf($endHalf); + } +} diff --git a/tests/State/WorkHourDayContextProviderTest.php b/tests/State/WorkHourDayContextProviderTest.php new file mode 100644 index 0000000..4353a2c --- /dev/null +++ b/tests/State/WorkHourDayContextProviderTest.php @@ -0,0 +1,154 @@ +security = $this->createStub(Security::class); + $this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class); + $this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); + $this->requestStack = new RequestStack(); + } + + public function testThrowsWhenAnonymous(): void + { + $this->security->method('getUser')->willReturn(null); + + $provider = new WorkHourDayContextProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->absenceRepository, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy() + ); + + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new Get()); + } + + public function testThrowsWhenDateFormatInvalid(): void + { + $this->requestStack->push(new Request(query: ['workDate' => '16-02-2026'])); + $this->security->method('getUser')->willReturn(new User()); + + $provider = new WorkHourDayContextProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->absenceRepository, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy() + ); + + $this->expectException(UnprocessableEntityHttpException::class); + $provider->provide(new Get()); + } + + public function testBuildsRowsWithAbsenceCredits(): void + { + $user = new User(); + $employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 35); + $absence = $this->buildAbsence($employee, '2026-02-16', '2026-02-16', true); + + $this->requestStack->push(new Request(query: ['workDate' => '2026-02-16'])); + $this->security->method('getUser')->willReturn($user); + $this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]); + $this->absenceRepository->method('findByDateAndEmployees')->willReturn([$absence]); + + $provider = new WorkHourDayContextProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->absenceRepository, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy() + ); + + $result = $provider->provide(new Get()); + + self::assertSame('2026-02-16', $result->workDate); + self::assertCount(1, $result->rows); + self::assertSame(1, $result->rows[0]['employeeId']); + self::assertSame('Maladie', $result->rows[0]['absenceLabel']); + self::assertSame('AM', $result->rows[0]['absenceHalf']); + self::assertSame(210, $result->rows[0]['creditedMinutes']); + } + + private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee + { + $contract = new Contract() + ->setName('Contrat') + ->setTrackingMode($trackingMode) + ->setWeeklyHours($weeklyHours) + ; + $employee = new Employee() + ->setFirstName('Jean') + ->setLastName('Test') + ->setContract($contract) + ; + $this->setEntityId($employee, $id); + + return $employee; + } + + private function buildAbsence(Employee $employee, string $startDate, string $endDate, bool $countAsWorked): Absence + { + $type = new AbsenceType() + ->setCode('MAL') + ->setLabel('Maladie') + ->setColor('#f00') + ->setCountAsWorkedHours($countAsWorked) + ; + + return new Absence() + ->setEmployee($employee) + ->setType($type) + ->setStartDate(new DateTime($startDate)) + ->setEndDate(new DateTime($endDate)) + ->setStartHalf(HalfDay::AM) + ->setEndHalf(HalfDay::AM) + ; + } + + private function setEntityId(object $entity, int $id): void + { + $reflection = new ReflectionObject($entity); + $property = $reflection->getProperty('id'); + $property->setAccessible(true); + $property->setValue($entity, $id); + } +} diff --git a/tests/State/WorkHourWeeklySummaryProviderTest.php b/tests/State/WorkHourWeeklySummaryProviderTest.php new file mode 100644 index 0000000..c3697a3 --- /dev/null +++ b/tests/State/WorkHourWeeklySummaryProviderTest.php @@ -0,0 +1,156 @@ +security = $this->createStub(Security::class); + $this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class); + $this->workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class); + $this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class); + $this->requestStack = new RequestStack(); + } + + public function testThrowsWhenAnonymous(): void + { + $this->security->method('getUser')->willReturn(null); + + $provider = new WorkHourWeeklySummaryProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->workHourRepository, + $this->absenceRepository, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy() + ); + + $this->expectException(AccessDeniedHttpException::class); + $provider->provide(new Get()); + } + + public function testBuildsWeeklyRowsWithOvertimeAndPresence(): void + { + $user = new User(); + $timeEmployee = $this->buildEmployee(1, 'TIME', 35, 'Alice'); + $presenceEmployee = $this->buildEmployee(2, 'PRESENCE', null, 'Bob'); + $employees = [$timeEmployee, $presenceEmployee]; + + $workHours = []; + foreach (['2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20'] as $date) { + $workHours[] = new WorkHour() + ->setEmployee($timeEmployee) + ->setWorkDate(new DateTimeImmutable($date)) + ->setMorningFrom('09:00') + ->setMorningTo('19:00') + ; + } + + $absenceType = new AbsenceType() + ->setCode('CP') + ->setLabel('Congé') + ->setColor('#000') + ->setCountAsWorkedHours(true) + ; + $presenceAbsence = new Absence() + ->setEmployee($presenceEmployee) + ->setType($absenceType) + ->setStartDate(new DateTime('2026-02-16')) + ->setEndDate(new DateTime('2026-02-16')) + ->setStartHalf(HalfDay::AM) + ->setEndHalf(HalfDay::PM) + ; + + $this->requestStack->push(new Request(query: ['weekStart' => '2026-02-16'])); + $this->security->method('getUser')->willReturn($user); + $this->employeeRepository->method('findScoped')->with($user)->willReturn($employees); + $this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn($workHours); + $this->absenceRepository->method('findForPrint')->willReturn([$presenceAbsence]); + + $provider = new WorkHourWeeklySummaryProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->workHourRepository, + $this->absenceRepository, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy() + ); + + $result = $provider->provide(new Get()); + + self::assertSame('2026-02-16', $result->weekStart); + self::assertSame('2026-02-22', $result->weekEnd); + self::assertCount(2, $result->rows); + + self::assertSame(3000, $result->rows[0]['weeklyTotalMinutes']); + self::assertSame(900, $result->rows[0]['weeklyOvertimeTotalMinutes']); + self::assertSame(120, $result->rows[0]['weeklyOvertime25Minutes']); + self::assertSame(210, $result->rows[0]['weeklyOvertime50Minutes']); + self::assertSame(1230, $result->rows[0]['weeklyRecoveryMinutes']); + + self::assertSame(1.0, $result->rows[1]['weeklyPresenceCount']); + self::assertSame(0, $result->rows[1]['weeklyOvertimeTotalMinutes']); + } + + private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours, string $firstName): Employee + { + $contract = new Contract() + ->setName($trackingMode) + ->setTrackingMode($trackingMode) + ->setWeeklyHours($weeklyHours) + ; + $employee = new Employee() + ->setFirstName($firstName) + ->setLastName('Test') + ->setContract($contract) + ; + $this->setEntityId($employee, $id); + + return $employee; + } + + private function setEntityId(object $entity, int $id): void + { + $reflection = new ReflectionObject($entity); + $property = $reflection->getProperty('id'); + $property->setAccessible(true); + $property->setValue($entity, $id); + } +}