feat : ajout des TU

This commit is contained in:
2026-02-19 22:06:54 +01:00
parent e8afd25566
commit d5230f685e
16 changed files with 684 additions and 19 deletions

7
.idea/SIRH.iml generated
View File

@@ -145,6 +145,13 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
<excludeFolder url="file://$MODULE_DIR$/LOG" />
<excludeFolder url="file://$MODULE_DIR$/frontend/.nuxt" />
<excludeFolder url="file://$MODULE_DIR$/frontend/.output" />
<excludeFolder url="file://$MODULE_DIR$/frontend/dist" />
<excludeFolder url="file://$MODULE_DIR$/frontend/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/public" />
<excludeFolder url="file://$MODULE_DIR$/var" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@@ -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

View File

@@ -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<Absence>
*/
final class AbsenceRepository extends ServiceEntityRepository
final class AbsenceRepository extends ServiceEntityRepository implements AbsenceReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Absence;
use App\Entity\Employee;
use DateTimeImmutable;
use DateTimeInterface;
interface AbsenceReadRepositoryInterface
{
/**
* @param list<Employee> $employees
*
* @return list<Absence>
*/
public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
/**
* @param list<Employee> $employees
*
* @return list<Absence>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array;
/**
* @return list<Absence>
*/
public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Employee;
use App\Entity\User;
interface EmployeeScopedRepositoryInterface
{
/**
* @return list<Employee>
*/
public function findScoped(User $user): array;
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use DateTimeImmutable;
use DateTimeInterface;
interface WorkHourReadRepositoryInterface
{
/**
* @param list<Employee> $employees
*
* @return list<WorkHour>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
}

View File

@@ -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<Employee>
*/
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<Employee> $employees */
// @var list<Employee> $employees
return $qb->getQuery()->getResult();
}
@@ -97,7 +98,7 @@ final class EmployeeRepository extends ServiceEntityRepository
;
}
/** @var list<Employee> $employees */
// @var list<Employee> $employees
return $qb->getQuery()->getResult();
}
}

View File

@@ -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<WorkHour>
*/
final class WorkHourRepository extends ServiceEntityRepository
final class WorkHourRepository extends ServiceEntityRepository implements WorkHourReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{

View File

@@ -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

View File

@@ -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,
) {}

View File

@@ -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,
) {}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Absence;
use App\Enum\HalfDay;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use DateTime;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class AbsenceSegmentsResolverTest extends TestCase
{
public function testResolveForSameDayMorningOnly(): void
{
$absence = new Absence()
->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'));
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTime;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class WorkedHoursCreditPolicyTest extends TestCase
{
public function testComputeCreditedMinutesFor35hHalfDay(): void
{
$policy = new WorkedHoursCreditPolicy();
$absence = $this->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'));
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Post;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\State\AbsenceWriteProcessor;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class AbsenceWriteProcessorTest extends TestCase
{
private AbsenceWriteProcessor $processor;
public function testPostSplitsRangeIntoDailyEntries(): void
{
$entityManager = $this->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);
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Get;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\State\WorkHourDayContextProvider;
use DateTime;
use PHPUnit\Framework\TestCase;
use ReflectionObject;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class WorkHourDayContextProviderTest extends TestCase
{
private Security $security;
private EmployeeScopedRepositoryInterface $employeeRepository;
private AbsenceReadRepositoryInterface $absenceRepository;
private RequestStack $requestStack;
protected function setUp(): void
{
$this->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);
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Get;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\HalfDay;
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 App\State\WorkHourWeeklySummaryProvider;
use DateTime;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionObject;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @internal
*/
final class WorkHourWeeklySummaryProviderTest extends TestCase
{
private Security $security;
private EmployeeScopedRepositoryInterface $employeeRepository;
private WorkHourReadRepositoryInterface $workHourRepository;
private AbsenceReadRepositoryInterface $absenceRepository;
private RequestStack $requestStack;
protected function setUp(): void
{
$this->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);
}
}