Files
SIRH/tests/State/WorkHourBulkUpsertProcessorTest.php
T
tristan d66288d061
Auto Tag Develop / tag (push) Successful in 13s
fix(heures) : garde backend anti-suppression sur grille périmée (delete explicite) (#36)
## Contexte (incident prod)
Le correctif #31 (dirty-tracking front) ne protège que les sessions chargeant le nouveau bundle. Un **vieil onglet** ouvert avant déploiement tourne encore sur l'ancien JS et envoie toute la grille périmée. Hier soir, un onglet ouvert le matin a **supprimé ~10 lignes d'heures** saisies dans la journée par d'autres utilisateurs (journal BDD à l'appui : 1 save = 2 updates + 8 deletes de lignes intactes).

Cause : le backend traitait toute **entrée vide comme une suppression**, sans aucune garde indépendante du client.

## Correctif — suppression sur intention explicite (`delete: true`)
`WorkHourBulkUpsertProcessor` ne supprime une ligne existante sur entrée vide **que si l'entrée porte `delete: true`**. Sinon → **no-op** (ligne préservée). Aucune grille périmée, quel que soit le client (vieil onglet inclus), ne peut plus détruire une saisie concurrente. La création de ligne technique de validation reste limitée à `null === $existing`.

Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** (helper `isEntryEmpty`, appliqué après le filtre dirty-tracking) → suppression métier conservée. Flag optionnel ajouté au DTO front (`WorkHourEntryPayload`) et back (`WorkHourBulkUpsert`), défaut `false`.

## Testabilité
Le processor dépend désormais des interfaces repo (`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface`, repos concrets `final` non mockables) → nouveau test unitaire `WorkHourBulkUpsertProcessorTest` (no-op sans flag / suppression avec flag / update normal).

## Limite résiduelle (choix : suppression explicite, pas verrou optimiste)
L'**édition explicite** d'une ligne sur données périmées peut encore écraser une saisie concurrente sur cette même ligne. Seule la **suppression** est blindée.

## Vérification
- **267 tests PHPUnit OK** (dont 3 nouveaux), via le pre-commit hook.
- Front : revue de code (pas de harnais de tests front).

## Doc
- `doc/hours-save-dirty-tracking.md`, `CLAUDE.md`, doc in-app (`documentation-content.ts`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #36
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-25 07:29:02 +00:00

140 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Post;
use App\ApiResource\WorkHourBulkUpsert;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractResolver;
use App\State\WorkHourBulkUpsertProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Garde anti-perte de données : une entrée vide ne supprime une ligne existante
* QUE si la suppression est explicitement demandée (flag `delete: true`).
* Sans ce flag, une grille périmée (vieil onglet) ne peut plus rien détruire.
*
* @internal
*/
final class WorkHourBulkUpsertProcessorTest extends TestCase
{
public function testEmptyEntryWithoutDeleteFlagPreservesExistingRow(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$em->expects(self::never())->method('remove');
$em->expects(self::once())->method('flush');
$result = $this->buildProcessor($em)->process(
$this->payload(['employeeId' => 7]),
new Post(),
);
self::assertSame(0, $result->deleted);
self::assertSame(1, $result->processed);
}
public function testEmptyEntryWithDeleteFlagRemovesExistingRow(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$em->expects(self::once())->method('remove');
$em->expects(self::once())->method('flush');
$result = $this->buildProcessor($em)->process(
$this->payload(['employeeId' => 7, 'delete' => true]),
new Post(),
);
self::assertSame(1, $result->deleted);
}
public function testNonEmptyEntryStillUpdatesRegardlessOfFlag(): void
{
$em = $this->createMock(EntityManagerInterface::class);
$em->expects(self::never())->method('remove');
$em->expects(self::once())->method('flush');
$result = $this->buildProcessor($em)->process(
$this->payload([
'employeeId' => 7,
'morningFrom' => '08:00',
'morningTo' => '12:30',
'afternoonFrom' => '14:00',
'afternoonTo' => '18:00',
]),
new Post(),
);
self::assertSame(1, $result->updated);
}
/**
* @param array<string, mixed> $entry
*/
private function payload(array $entry): WorkHourBulkUpsert
{
$payload = new WorkHourBulkUpsert();
$payload->workDate = '2026-06-24';
$payload->entries = [$entry];
return $payload;
}
private function buildProcessor(EntityManagerInterface $em): WorkHourBulkUpsertProcessor
{
$user = new User()->setUsername('Elodie')->setRoles(['ROLE_ADMIN']);
$employee = new Employee()->setFirstName('Delphine')->setLastName('BACHELIER');
// Ligne existante NON vide (journée complète saisie entre-temps par un autre utilisateur).
$existing = new WorkHour()
->setEmployee($employee)
->setWorkDate(new DateTimeImmutable('2026-06-24'))
->setMorningFrom('08:00')
->setMorningTo('12:00')
->setAfternoonFrom('14:00')
->setAfternoonTo('18:00')
;
$contract = new Contract()->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($user);
$employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
$employeeRepository->method('findAccessibleByIds')->willReturn([7 => $employee]);
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$workHourRepository->method('findByDateAndEmployeesIndexedByEmployeeId')->willReturn([7 => $existing]);
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$absenceRepository->method('findByDateAndEmployees')->willReturn([]);
$contractResolver = $this->createStub(EmployeeContractResolver::class);
$contractResolver->method('resolveForEmployeeAndDate')->willReturn($contract);
$contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(false);
return new WorkHourBulkUpsertProcessor(
$em,
$security,
$employeeRepository,
$workHourRepository,
$absenceRepository,
$contractResolver,
$this->createStub(AuditLogger::class),
);
}
}