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:
+187
@@ -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')
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user