fix(heures) : garde backend anti-suppression sur grille périmée (delete explicite) (#36)
Auto Tag Develop / tag (push) Successful in 13s
Auto Tag Develop / tag (push) Successful in 13s
## 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>
This commit was merged in pull request #36.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
<?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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user