Files
Starseed/tests/Module/Logistique/Application/Service/ImmatriculationNormalizationTest.php
T
Matthieu ab15452459 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é.
2026-06-18 12:01:58 +02:00

163 lines
6.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Logistique\Application\Service;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\Validator\Exception\ValidationException;
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\Domain\Exception\InvalidImmatriculationException;
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\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Normalisation de l'immatriculation (RG-5.01 / RG-5.10 / § 6 / § 2.10) — test
* unitaire en deux volets, sans BDD ni HTTP :
*
* 1. Le WeighingTicketFieldNormalizer applique trim + UPPER ; hors « Tout format »
* il ramene la saisie au masque SIV canonique XX-000-XX (separateurs/espaces
* ignores puis re-poses) et leve InvalidImmatriculationException si le squelette
* 2-3-2 n'est pas respecte. En « Tout format », seul trim + UPPER s'applique.
* 2. Le WeighingTicketProcessor traduit cette exception de domaine en 422 portant
* un propertyPath « immatriculation » (mapping inline useFormErrors, ERP-101).
*
* @internal
*/
final class ImmatriculationNormalizationTest extends TestCase
{
private WeighingTicketFieldNormalizer $normalizer;
protected function setUp(): void
{
$this->normalizer = new WeighingTicketFieldNormalizer();
}
// === Volet 1 : normalisation pure (masque + « Tout format ») ===
#[DataProvider('provideMaskedPlates')]
public function testMaskedPlateIsReformattedToCanonicalSiv(string $input): void
{
self::assertSame('AB-123-CD', $this->normalizer->normalizeImmatriculation($input, false));
}
/**
* @return iterable<string, array{string}>
*/
public static function provideMaskedPlates(): iterable
{
yield 'deja canonique' => ['AB-123-CD'];
yield 'minuscules nues' => ['ab123cd'];
yield 'espaces' => ['AB 123 CD'];
yield 'minuscules tirets'=> ['ab-123-cd'];
yield 'espaces de garde' => [' ab-123-cd '];
}
public function testInvalidPlateWithoutFreeFormatThrows(): void
{
$this->expectException(InvalidImmatriculationException::class);
$this->normalizer->normalizeImmatriculation('ABC-12-D', false);
}
public function testFreeFormatBypassesTheMask(): void
{
// Ancienne plaque / engin : aucune contrainte de masque, juste trim + UPPER.
self::assertSame('1234 WW 75', $this->normalizer->normalizeImmatriculation(' 1234 ww 75 ', true));
self::assertSame('ENGIN-XYZ', $this->normalizer->normalizeImmatriculation('engin-xyz', true));
}
public function testNullAndBlankAreNormalizedToNull(): void
{
self::assertNull($this->normalizer->normalizeImmatriculation(null, false));
self::assertNull($this->normalizer->normalizeImmatriculation(' ', false));
self::assertNull($this->normalizer->normalizeImmatriculation(' ', true));
}
public function testOtherLabelIsTrimmedAndBlankBecomesNull(): void
{
self::assertSame('Reprise interne', $this->normalizer->normalizeOtherLabel(' Reprise interne '));
self::assertNull($this->normalizer->normalizeOtherLabel(' '));
self::assertNull($this->normalizer->normalizeOtherLabel(null));
}
// === Volet 2 : mapping 422 par le Processor (RG-5.01, ERP-101) ===
public function testProcessorMapsInvalidPlateTo422OnImmatriculationPath(): void
{
$ticket = (new WeighingTicket())
->setCounterpartyType('AUTRE')
->setOtherLabel('Reprise')
->setImmatriculation('PLAQUE INVALIDE')
->setPlateFreeFormat(false)
;
try {
$this->makeProcessor()->process($ticket, new Post());
self::fail('Une ValidationException (422) etait attendue sur une immatriculation invalide.');
} catch (ValidationException $e) {
$paths = [];
foreach ($e->getConstraintViolationList() as $violation) {
$paths[] = $violation->getPropertyPath();
}
self::assertContains('immatriculation', $paths);
}
}
public function testProcessorReformatsValidPlateAndHonorsFreeFormat(): void
{
// Masque applique a la persistance (saisie nue -> canonique).
$masked = (new WeighingTicket())
->setCounterpartyType('AUTRE')
->setOtherLabel('Reprise')
->setImmatriculation('ab123cd')
->setPlateFreeFormat(false)
;
$this->makeProcessor()->process($masked, new Post());
self::assertSame('AB-123-CD', $masked->getImmatriculation());
// « Tout format » : la plaque libre passe (UPPER seulement), aucune 422.
$free = (new WeighingTicket())
->setCounterpartyType('AUTRE')
->setOtherLabel('Reprise')
->setImmatriculation('vieux 4321 zz')
->setPlateFreeFormat(true)
;
$this->makeProcessor()->process($free, new Post());
self::assertSame('VIEUX 4321 ZZ', $free->getImmatriculation());
}
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,
);
}
}