test(logistique) : tests PHPUnit RG-5.01→5.10 + capture contrat JSON (ERP-187) (#137)
Auto Tag Develop / tag (push) Successful in 8s
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:
+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,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')
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user