test(logistique) : tests PHPUnit RG-5.01→5.10 + capture contrat JSON (ERP-187)

Couverture des règles de gestion du M5 (tickets de pesée) et capture de la
réponse JSON réelle (DoD § 4.0.bis) avant les écrans front.

Tests unitaires (Processor/Normalizer/Callback, sans BDD ni HTTP) :
- NetWeightTest (RG-5.05) : net = plein − vide, null si pesée manquante, recalcul PATCH.
- CounterpartyValidationTest (RG-5.03) : présence par branche (propertyPath) + exclusivité.
- ImmatriculationNormalizationTest (RG-5.01/5.10) : masque XX-000-XX, « Tout format », 422.

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 (client embarqué,
  plateFreeFormat présent, number formaté, netWeight = full − empty) + dump JSON.
- WeighingTicketRBACMatrixTest (§ 5.2) : admin/bureau/usine OK, compta/commerciale 403,
  anonyme 401.

DSD/stub/reading déjà couverts (ERP-184/185). spec-back.md § 4.0.bis : JSON réel collé.
This commit is contained in:
Matthieu
2026-06-18 12:01:58 +02:00
parent 691ed04b71
commit ab15452459
8 changed files with 1084 additions and 36 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,130 @@
<?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` pilote
* EntityManager::contains() : false => creation (POST, attribution site/numero),
* true => entite geree (PATCH, ni site ni numero retouches).
*/
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')
;
}
}