Périmètre 1-6 du design 2026-05-22-absence-legal-compliance-fixes (points lourds — ancienneté, CP pendant maladie, rétention — reportés en backlog). - Événements familiaux sans solde : AbsenceType::decrementsBalance() ne vaut true que pour les CP. Mariage/PACS, naissance, décès = droits par événement ; congé parental = suspension ; maladie = Sécu. Plus de solde fantôme. - Décès : daysPerEvent = null (selon lien de parenté) + motif obligatoire à la création (REST + MCP), les minimums légaux étant rappelés dans l'aide. - Ajout du congé naissance (type, policy 3 j, justificatif, libellés/couleur front). - Garde-fou demi-journée : -0,5 appliqué uniquement si le jour-borne est réellement décompté (corrige un sous-décompte week-end/férié) — TDD. - CCN documentée : paramètre app.absence.convention = "Syntec (IDCC 1486)", rappelée en sous-titre admin et dans l'aide /help. Tests : AbsenceDayCalculatorTest (garde-fou demi-journée), AbsenceRequestLifecycle (motif décès obligatoire + aucun solde touché). make test 52/52, build Nuxt OK. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
215 lines
7.6 KiB
PHP
215 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Functional\Mcp;
|
|
|
|
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);
|
|
}
|
|
|
|
$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 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),
|
|
);
|
|
}
|
|
}
|