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);
+ }
+}