test(logistique) : tests PHPUnit RG-5.01→5.10 + capture contrat JSON (ERP-187) (#137)
Auto Tag Develop / tag (push) Successful in 8s

## ERP-187 (1.7) — Tests PHPUnit RG-5.01→5.10 + capture contrat JSON

Couvre les règles de gestion du M5 (tickets de pesée) par des tests PHPUnit et capture la **réponse JSON réelle** (DoD § 4.0.bis) collée dans `spec-back.md` avant les écrans front.

### Tests unitaires (Processor / Normalizer / Callback — sans BDD ni HTTP)
- **NetWeightTest** (RG-5.05) : net = plein − vide, `null` si une pesée manque, recalcul au PATCH.
- **CounterpartyValidationTest** (RG-5.03) : présence du champ requis par branche (propertyPath `client`/`supplier`/`otherLabel`) + exclusivité (null-ification hors-branche).
- **ImmatriculationNormalizationTest** (RG-5.01/5.10) : masque `XX-000-XX`, « Tout format », mapping 422 sur `immatriculation`.

### Tests fonctionnels (API réelle)
- **WeighingTicketNumberingTest** (RG-5.02/5.09) : format `{siteCode}-TP-{NNNN}`, séquence par site, isolation inter-sites, immuabilité numéro/site au PATCH.
- **WeighingTicketSerializationContractTest** (DoD § 4.0.bis) : 4 pièges verts (client embarqué, `plateFreeFormat` présent, `number` formaté, `netWeight` = full − empty) + dump JSON via `WEIGHING_TICKET_DOD_DUMP`.
- **WeighingTicketRBACMatrixTest** (§ 5.2) : admin/bureau/usine OK, compta/commerciale 403, anonyme 401.

> DSD / stub pont bascule / endpoint pesée déjà couverts (ERP-184/185).

### DoD
- `spec-back.md § 4.0.bis` : **JSON réel** (liste + détail) collé, 4 pièges marqués  — feu vert front.

### Vérifications
- `make test` complet **vert** : 848 tests, 6302 assertions (0 échec ; deprecations/notices PHPUnit seuls).
- `make php-cs-fixer-allow-risky` : 0 correction.

Empilée sur ERP-186 (stack M5).

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #137
This commit was merged in pull request #137.
This commit is contained in:
2026-06-18 13:33:39 +00:00
parent b4e550b5de
commit 36e947fd8e
10 changed files with 1211 additions and 41 deletions
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Commercial\Domain\Entity\Client;
use App\Module\Commercial\Domain\Entity\Supplier;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Coherence de la contrepartie (RG-5.03 / § 2.9) — test unitaire en deux volets,
* sans BDD ni HTTP :
*
* 1. PRESENCE (Assert\Callback de l'entite) : selon counterpartyType, le champ
* associe est obligatoire ; la violation porte le bon propertyPath
* (client / supplier / otherLabel) pour le mapping inline useFormErrors
* (ERP-101). Valide via le validateur Symfony sur l'entite (les attributs
* #[Assert\*] sont lus directement).
* 2. EXCLUSIVITE (WeighingTicketProcessor) : les champs hors-branche sont forces
* a null avant persistance (garde-fou des CHECK Postgres chk_wt_*_branch).
*
* @internal
*/
final class CounterpartyValidationTest extends TestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
$this->validator = Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
;
}
// === Volet 1 : presence du champ requis (Assert\Callback) ===
public function testClientBranchRequiresClient(): void
{
$ticket = $this->baseTicket('CLIENT');
// Sans client : violation attendue sur le path « client ».
self::assertContains('client', $this->violationPaths($ticket));
// Avec client : plus de violation sur « client ».
$ticket->setClient(new Client());
self::assertNotContains('client', $this->violationPaths($ticket));
}
public function testSupplierBranchRequiresSupplier(): void
{
$ticket = $this->baseTicket('FOURNISSEUR');
self::assertContains('supplier', $this->violationPaths($ticket));
$ticket->setSupplier(new Supplier());
self::assertNotContains('supplier', $this->violationPaths($ticket));
}
public function testOtherBranchRequiresOtherLabel(): void
{
$ticket = $this->baseTicket('AUTRE');
// Ni null ni chaine vide apres trim ne suffisent (RG-5.03).
self::assertContains('otherLabel', $this->violationPaths($ticket));
$ticket->setOtherLabel(' ');
self::assertContains('otherLabel', $this->violationPaths($ticket));
$ticket->setOtherLabel('Reprise interne');
self::assertNotContains('otherLabel', $this->violationPaths($ticket));
}
// === Volet 2 : exclusivite (le Processor null-ifie les champs hors-branche) ===
public function testClientBranchNullifiesSupplierAndOtherLabel(): void
{
$ticket = $this->baseTicket('CLIENT')
->setClient(new Client())
->setSupplier(new Supplier())
->setOtherLabel('parasite')
;
$this->makeProcessor()->process($ticket, new Post());
self::assertInstanceOf(Client::class, $ticket->getClient());
self::assertNull($ticket->getSupplier());
self::assertNull($ticket->getOtherLabel());
}
public function testSupplierBranchNullifiesClientAndOtherLabel(): void
{
$ticket = $this->baseTicket('FOURNISSEUR')
->setClient(new Client())
->setSupplier(new Supplier())
->setOtherLabel('parasite')
;
$this->makeProcessor()->process($ticket, new Post());
self::assertInstanceOf(Supplier::class, $ticket->getSupplier());
self::assertNull($ticket->getClient());
self::assertNull($ticket->getOtherLabel());
}
public function testOtherBranchNullifiesClientAndSupplierAndTrimsLabel(): void
{
$ticket = $this->baseTicket('AUTRE')
->setClient(new Client())
->setSupplier(new Supplier())
->setOtherLabel(' Reprise interne ')
;
$this->makeProcessor()->process($ticket, new Post());
self::assertNull($ticket->getClient());
self::assertNull($ticket->getSupplier());
self::assertSame('Reprise interne', $ticket->getOtherLabel());
}
/**
* Ticket minimal VALIDE hors contrepartie : counterpartyType + immatriculation
* renseignes, afin d'isoler la violation de contrepartie (et pas un NotBlank
* collateral) dans le volet 1.
*/
private function baseTicket(string $type): WeighingTicket
{
return (new WeighingTicket())
->setCounterpartyType($type)
->setImmatriculation('AB-123-CD')
;
}
/**
* Liste des propertyPath des violations de l'entite.
*
* @return list<string>
*/
private function violationPaths(WeighingTicket $ticket): array
{
$paths = [];
foreach ($this->validator->validate($ticket) as $violation) {
$paths[] = $violation->getPropertyPath();
}
return $paths;
}
private function makeProcessor(): WeighingTicketProcessor
{
$persist = $this->createStub(ProcessorInterface::class);
$persist->method('process')->willReturnArgument(0);
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn(
(new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233'))->setCode('86'),
);
$numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class);
$numberAllocator->method('allocate')->willReturn('86-TP-0001');
$em = $this->createStub(EntityManagerInterface::class);
$em->method('contains')->willReturn(false);
return new WeighingTicketProcessor(
$persist,
$siteProvider,
$numberAllocator,
$this->createStub(DsdAllocatorInterface::class),
new WeighingTicketFieldNormalizer(),
$em,
);
}
}
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Logistique\Application\Service\DsdAllocatorInterface;
use App\Module\Logistique\Application\Service\WeighingTicketFieldNormalizer;
use App\Module\Logistique\Application\Service\WeighingTicketNumberAllocatorInterface;
use App\Module\Logistique\Domain\Entity\WeighingTicket;
use App\Module\Logistique\Infrastructure\ApiPlatform\State\Processor\WeighingTicketProcessor;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
/**
* Poids net (RG-5.05 / § 2.8) — test unitaire du WeighingTicketProcessor, sans BDD
* ni HTTP (stubs purs, meme approche que WeighbridgeReadingProcessorTest).
*
* Verifie la regle metier seule : net_weight = full_weight - empty_weight des que
* les DEUX poids sont presents, null tant que l'une des deux pesees manque, et
* recalcul a l'edition (PATCH).
*
* @internal
*/
final class NetWeightTest extends TestCase
{
public function testNetIsFullMinusEmptyWhenBothPresent(): void
{
$ticket = new WeighingTicket()
->setEmptyWeight(7150)
->setFullWeight(14300)
;
$this->makeProcessor(isNew: true)->process($ticket, new Post());
// 14300 - 7150 = 7150 (exemple maquette § 2.8).
self::assertSame(7150, $ticket->getNetWeight());
}
public function testNetIsNullWhenFullWeightMissing(): void
{
$ticket = new WeighingTicket()->setEmptyWeight(7150);
$this->makeProcessor(isNew: true)->process($ticket, new Post());
self::assertNull($ticket->getNetWeight());
}
public function testNetIsNullWhenEmptyWeightMissing(): void
{
$ticket = new WeighingTicket()->setFullWeight(14300);
$this->makeProcessor(isNew: true)->process($ticket, new Post());
self::assertNull($ticket->getNetWeight());
}
public function testNetIsNullWhenNoWeighing(): void
{
$ticket = new WeighingTicket();
$this->makeProcessor(isNew: true)->process($ticket, new Post());
self::assertNull($ticket->getNetWeight());
}
/**
* RG-5.05 : a la modification (PATCH = entite deja geree par l'ORM), le net est
* recalcule a partir des poids courants — ici la pesee a plein renseignee apres
* coup complete le ticket.
*/
public function testNetIsRecomputedOnPatch(): void
{
$ticket = new WeighingTicket()
->setSite($this->site())
->setEmptyWeight(7150)
->setFullWeight(20000)
;
$this->makeProcessor(isNew: false)->process($ticket, new Patch());
self::assertSame(12850, $ticket->getNetWeight());
}
/**
* Construit le Processor avec des dependances stubbees. `isNew` porte le sens
* metier : true => creation (POST, attribution site/numero), false => entite
* geree (PATCH, ni site ni numero retouches). Il est INVERSE pour stubber
* EntityManager::contains() (qui renvoie true pour une entite deja persistee),
* d'ou `willReturn(!$isNew)` plus bas.
*/
private function makeProcessor(bool $isNew): WeighingTicketProcessor
{
$persist = $this->createStub(ProcessorInterface::class);
$persist->method('process')->willReturnArgument(0);
$siteProvider = $this->createStub(CurrentSiteProviderInterface::class);
$siteProvider->method('get')->willReturn($this->site());
$numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class);
$numberAllocator->method('allocate')->willReturn('86-TP-0001');
$dsdAllocator = $this->createStub(DsdAllocatorInterface::class);
$dsdAllocator->method('next')->willReturn(99);
$em = $this->createStub(EntityManagerInterface::class);
$em->method('contains')->willReturn(!$isNew);
return new WeighingTicketProcessor(
$persist,
$siteProvider,
$numberAllocator,
$dsdAllocator,
new WeighingTicketFieldNormalizer(),
$em,
);
}
private function site(): Site
{
// getId() reste null : numberAllocator et dsdAllocator sont stubbes, donc
// aucune requete reelle ne depend de l'id du site.
return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233')
->setCode('86')
;
}
}