Files
Lesstime/tests/Functional/Mcp/AbsenceRequestLifecycleTest.php
Matthieu 65df36dd1a fix(absences) : garde-fou solde négatif à l'approbation + cohérence fixture
- AbsenceBalanceService::availableForRequest() : jours disponibles (acquis N-1
  + en cours N − pris) pour la période de la demande, null si type non suivi.
- Blocage de l'approbation si countedDays > disponible, dans les deux chemins
  (REST AbsenceReviewProcessor + MCP ReviewAbsenceRequestTool), comme le motif
  décès. Les CP en cours d'acquisition restent posables, mais pas au-delà du
  droit total (plus de solde négatif silencieux à l'approbation).
- Fixture : demande pending CP d'alice replacée dans sa période de référence
  2025-2026 (26→29/05/2026, 4 j ouvrés) et solde pending aligné (5 → 4) ;
  plus de "en attente" orphelin non lié à une demande.
- Test fonctionnel testApproveBeyondAvailableBalanceIsBlocked + employé de test
  doté d'un droit pour que les approbations existantes passent le garde-fou.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:48:49 +02:00

252 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Functional\Mcp;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Absence\CancelAbsenceRequestTool;
use App\Mcp\Tool\Absence\CreateAbsenceRequestTool;
use App\Mcp\Tool\Absence\ReviewAbsenceRequestTool;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @internal
*/
class AbsenceRequestLifecycleTest extends KernelTestCase
{
private EntityManagerInterface $em;
private User $employee;
private User $admin;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
// Employé dédié au test.
$this->employee = new User();
$this->employee->setUsername('mcp-test-employee-'.uniqid());
$this->employee->setPassword('x');
$this->employee->setRoles(['ROLE_USER']);
$this->employee->setIsEmployee(true);
$this->employee->setReferencePeriodStart('06-01');
$this->em->persist($this->employee);
$this->admin = new User();
$this->admin->setUsername('mcp-test-admin-'.uniqid());
$this->admin->setPassword('x');
$this->admin->setRoles(['ROLE_ADMIN']);
$this->em->persist($this->admin);
// Policy CP active (créée si absente — findOneByType peut déjà exister via fixtures).
$policy = $this->em->getRepository(AbsencePolicy::class)->findOneBy(['type' => AbsenceType::PaidLeave]);
if (null === $policy) {
$policy = new AbsencePolicy();
$policy->setType(AbsenceType::PaidLeave);
$policy->setCountWorkingDaysOnly(true);
$policy->setActive(true);
$this->em->persist($policy);
} else {
$policy->setActive(true);
}
// Entitlement so CP requests can be approved without breaching the
// no-negative-balance guard (period of the June 2026 test requests).
$balance = new AbsenceBalance();
$balance->setUser($this->employee);
$balance->setType(AbsenceType::PaidLeave);
$balance->setPeriod('2026-2027');
$balance->setAcquired(25.0);
$this->em->persist($balance);
$this->em->flush();
}
public function testCreateReservesPendingDays(): void
{
$tool = $this->createTool($this->admin);
// Lundi 2026-06-01 → vendredi 2026-06-05 = 5 jours ouvrés.
$json = ($tool)(
$this->employee->getId(),
'cp',
'2026-06-01',
'2026-06-05',
);
$data = json_decode($json, true);
self::assertSame('pending', $data['status']);
// JSON ne préserve pas le type float pour un entier (5.0 -> 5) : on compare la valeur.
self::assertSame(5.0, (float) $data['countedDays']);
self::assertSame($this->employee->getId(), $data['user']['id']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
;
self::assertNotNull($balance);
self::assertSame(5.0, $balance->getPending());
}
public function testApproveMovesPendingToTaken(): void
{
$created = json_decode(
($this->createTool($this->admin))($this->employee->getId(), 'cp', '2026-06-01', '2026-06-05'),
true,
);
$json = ($this->reviewTool($this->admin))($created['id'], 'approve');
$data = json_decode($json, true);
self::assertSame('approved', $data['status']);
self::assertSame($this->admin->getId(), $data['reviewedBy']['id']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
;
self::assertSame(0.0, $balance->getPending());
self::assertSame(5.0, $balance->getTaken());
}
public function testApproveBeyondAvailableBalanceIsBlocked(): void
{
$created = json_decode(
($this->createTool($this->admin))($this->employee->getId(), 'cp', '2026-06-01', '2026-06-05'),
true,
);
// Shrink the entitlement below the 5 requested days.
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
;
$balance->setAcquired(2.0);
$balance->setAcquiring(0.0);
$this->em->flush();
try {
($this->reviewTool($this->admin))($created['id'], 'approve');
self::fail('Expected approval to be blocked when it would breach the balance.');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('below zero', $e->getMessage());
}
// Approval bailed out before mutating: nothing moved to taken, days stay reserved.
self::assertSame(0.0, $balance->getTaken());
self::assertSame(5.0, $balance->getPending());
}
public function testAdminCancelApprovedReleasesTaken(): void
{
$created = json_decode(
($this->createTool($this->admin))($this->employee->getId(), 'cp', '2026-06-01', '2026-06-05'),
true,
);
($this->reviewTool($this->admin))($created['id'], 'approve');
$data = json_decode(($this->cancelTool($this->admin))($created['id']), true);
self::assertSame('cancelled', $data['status']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::PaidLeave, '2026-2027')
;
self::assertSame(0.0, $balance->getTaken());
self::assertSame(0.0, $balance->getPending());
}
public function testBereavementRequiresReasonAndDoesNotTouchBalance(): void
{
// Ensure an active bereavement policy exists (no fixed entitlement).
$policy = $this->em->getRepository(AbsencePolicy::class)->findOneBy(['type' => AbsenceType::Bereavement]);
if (null === $policy) {
$policy = new AbsencePolicy();
$policy->setType(AbsenceType::Bereavement);
$policy->setCountWorkingDaysOnly(true);
$this->em->persist($policy);
}
$policy->setActive(true);
$this->em->flush();
$tool = $this->createTool($this->admin);
// Without a reason → rejected.
try {
($tool)($this->employee->getId(), 'deces', '2026-06-10', '2026-06-12');
self::fail('Expected an exception for bereavement without a reason.');
} catch (InvalidArgumentException $e) {
self::assertStringContainsString('reason', $e->getMessage());
}
// With a reason → accepted, and no balance row is created (per-event right).
$data = json_decode(
($tool)($this->employee->getId(), 'deces', '2026-06-10', '2026-06-12', null, null, 'Décès grand-parent'),
true,
);
self::assertSame('pending', $data['status']);
$balance = self::getContainer()->get(AbsenceBalanceRepository::class)
->findOneForPeriod($this->employee, AbsenceType::Bereavement, '2026')
;
self::assertNull($balance, 'Bereavement must not create or touch any balance.');
}
private function securityFor(User $user): Security
{
$security = $this->createMock(Security::class);
$security->method('isGranted')->willReturn(true);
$security->method('getUser')->willReturn($user);
return $security;
}
private function createTool(User $actor): CreateAbsenceRequestTool
{
$c = self::getContainer();
return new CreateAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(UserRepository::class),
$c->get(AbsencePolicyRepository::class),
$c->get(AbsenceRequestRepository::class),
$c->get(AbsenceDayCalculator::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
);
}
private function reviewTool(User $actor): ReviewAbsenceRequestTool
{
$c = self::getContainer();
return new ReviewAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(AbsenceRequestRepository::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
);
}
private function cancelTool(User $actor): CancelAbsenceRequestTool
{
$c = self::getContainer();
return new CancelAbsenceRequestTool(
$c->get(EntityManagerInterface::class),
$c->get(AbsenceRequestRepository::class),
$c->get(AbsenceBalanceService::class),
$this->securityFor($actor),
);
}
}