test(logistique) : durcissement tests M5 — embed FOURNISSEUR symetrique + propertyPath 422 (ERP-187)

- WeighingTicketSerializationContractTest : couvre la branche FOURNISSEUR (supplier embarque, client null) en plus de CLIENT (piege #1 symetrique, spec § 4.0.bis).
- AbstractWeighingTicketApiTestCase : helpers seedTestSupplier + payload FOURNISSEUR + purge supplier au tearDown.
- WeighbridgeReadingApiTest : les 422 (mode invalide / poids manquant) verifient desormais le propertyPath (garde-fou ERP-101), pas seulement le code HTTP.
- NetWeightTest : docbloc isNew/contains() clarifie.
This commit is contained in:
Matthieu
2026-06-18 14:26:33 +02:00
parent b036c72615
commit 47fdbc43ff
4 changed files with 138 additions and 16 deletions
@@ -6,6 +6,7 @@ namespace App\Tests\Module\Logistique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client; use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Commercial\Domain\Entity\Client as ClientEntity; use App\Module\Commercial\Domain\Entity\Client as ClientEntity;
use App\Module\Commercial\Domain\Entity\Supplier as SupplierEntity;
use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
@@ -31,16 +32,28 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
/** Prefixe companyName des Client seedes par ces tests (purge ciblee). */ /** Prefixe companyName des Client seedes par ces tests (purge ciblee). */
protected const string TEST_CLIENT_PREFIX = 'ZTESTWTAPI'; protected const string TEST_CLIENT_PREFIX = 'ZTESTWTAPI';
/** Prefixe companyName des Supplier seedes par ces tests (purge ciblee). */
protected const string TEST_SUPPLIER_PREFIX = 'ZTESTWTAPISUP';
protected function tearDown(): void protected function tearDown(): void
{ {
$em = $this->getEm(); $em = $this->getEm();
// Tickets referencant un Client de test d'abord (FK client_id RESTRICT) : // Tickets referencant un Client OU un Supplier de test d'abord (FK
// purge DBAL brute pour liberer les Client avant de les supprimer. // client_id / supplier_id RESTRICT) : purge DBAL brute pour liberer la
// contrepartie avant de la supprimer. Un ticket FOURNISSEUR a client_id
// NULL -> il faut bien purger aussi par supplier_id (sinon ticket orphelin).
$em->getConnection()->executeStatement( $em->getConnection()->executeStatement(
'DELETE FROM weighing_ticket WHERE client_id IN (SELECT id FROM client WHERE company_name LIKE :p)', 'DELETE FROM weighing_ticket WHERE client_id IN (SELECT id FROM client WHERE company_name LIKE :p)',
['p' => self::TEST_CLIENT_PREFIX.'%'], ['p' => self::TEST_CLIENT_PREFIX.'%'],
); );
$em->getConnection()->executeStatement(
'DELETE FROM weighing_ticket WHERE supplier_id IN (SELECT id FROM supplier WHERE company_name LIKE :p)',
['p' => self::TEST_SUPPLIER_PREFIX.'%'],
);
$em->createQuery('DELETE FROM '.SupplierEntity::class.' s WHERE s.companyName LIKE :p')
->setParameter('p', self::TEST_SUPPLIER_PREFIX.'%')->execute()
;
$em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p') $em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p')
->setParameter('p', self::TEST_CLIENT_PREFIX.'%')->execute() ->setParameter('p', self::TEST_CLIENT_PREFIX.'%')->execute()
; ;
@@ -138,6 +151,28 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
return '/api/clients/'.$client->getId(); return '/api/clients/'.$client->getId();
} }
/**
* Seede un Supplier minimal (companyName prefixe pour la purge). Sert de
* contrepartie aux tickets de test en branche FOURNISSEUR (RG-5.03).
*/
protected function seedTestSupplier(string $label): SupplierEntity
{
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(3)), 0, 6);
$supplier = new SupplierEntity();
$supplier->setCompanyName(mb_strtoupper(self::TEST_SUPPLIER_PREFIX.' '.$label.' '.$suffix, 'UTF-8'));
$em->persist($supplier);
$em->flush();
return $supplier;
}
protected function supplierIri(SupplierEntity $supplier): string
{
return '/api/suppliers/'.$supplier->getId();
}
/** /**
* Payload POST de reference : contrepartie Client, pesee a vide + a plein en * Payload POST de reference : contrepartie Client, pesee a vide + a plein en
* mode AUTO (le Processor (re)alloue les DSD et calcule le net = 14300 - 7150). * mode AUTO (le Processor (re)alloue les DSD et calcule le net = 14300 - 7150).
@@ -160,6 +195,29 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
]; ];
} }
/**
* Payload POST de reference en branche FOURNISSEUR (RG-5.03) — miroir de
* validClientTicketPayload, contrepartie Supplier. Sert a prouver l'embed
* symetrique de `supplier` (spec § 4.0.bis piege #1).
*
* @return array<string, mixed>
*/
protected function validSupplierTicketPayload(SupplierEntity $supplier): array
{
return [
'counterpartyType' => 'FOURNISSEUR',
'supplier' => $this->supplierIri($supplier),
'immatriculation' => 'AB-123-CD',
'plateFreeFormat' => false,
'emptyDate' => '2026-06-17T09:00:00+02:00',
'emptyWeight' => 7150,
'emptyMode' => 'AUTO',
'fullDate' => '2026-06-17T09:12:00+02:00',
'fullWeight' => 14300,
'fullMode' => 'AUTO',
];
}
/** /**
* POST un ticket et renvoie la reponse (assertions de statut a la charge de * POST un ticket et renvoie la reponse (assertions de statut a la charge de
* l'appelant). * l'appelant).
@@ -177,7 +235,7 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
* *
* @param array<string, mixed> $collection * @param array<string, mixed> $collection
* *
* @return array<string, mixed>|null * @return null|array<string, mixed>
*/ */
protected function memberById(array $collection, int $id): ?array protected function memberById(array $collection, int $id): ?array
{ {
@@ -29,9 +29,11 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
$em = $this->getEm(); $em = $this->getEm();
$em->getConnection()->executeStatement('DELETE FROM weighbridge_dsd_counter'); $em->getConnection()->executeStatement('DELETE FROM weighbridge_dsd_counter');
$em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p') $em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p')
->setParameter('p', 'testuser_%')->execute(); ->setParameter('p', 'testuser_%')->execute()
;
$em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p') $em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p')
->setParameter('p', 'test_%')->execute(); ->setParameter('p', 'test_%')->execute()
;
parent::tearDown(); parent::tearDown();
} }
@@ -95,24 +97,45 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase
{ {
$client = $this->manageClientWithCurrentSite(); $client = $this->manageClientWithCurrentSite();
$client->request('POST', '/api/weighbridge_readings', [ $response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'INVALID'], 'json' => ['mode' => 'INVALID'],
]); ]);
// Garde-fou ERP-101 : la 422 doit cibler `mode` (Assert\Choice), pas juste
// un bon code HTTP — sinon une violation sur le mauvais champ passerait.
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'mode');
} }
public function testManualWeighingRequiresWeight(): void public function testManualWeighingRequiresWeight(): void
{ {
$client = $this->manageClientWithCurrentSite(); $client = $this->manageClientWithCurrentSite();
$client->request('POST', '/api/weighbridge_readings', [ $response = $client->request('POST', '/api/weighbridge_readings', [
'headers' => ['Content-Type' => 'application/ld+json'], 'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['mode' => 'MANUAL'], 'json' => ['mode' => 'MANUAL'],
]); ]);
// Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualWeight).
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
self::assertViolationOnPath($response, 'weight');
}
/**
* Garde-fou ERP-101 (miroir AbstractWeighingTicketApiTestCase) : une 422 doit
* porter une violation sur le `propertyPath` attendu, consommable inline par
* useFormErrors cote front, pas seulement le bon statut HTTP.
*/
private static function assertViolationOnPath(object $response, string $path): void
{
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
self::assertContains(
$path,
$paths,
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
);
} }
/** /**
@@ -6,7 +6,8 @@ namespace App\Tests\Module\Logistique\Api;
/** /**
* Contrat de serialisation du ticket de pesee (M5, spec-back § 4.0 / § 4.0.bis). * Contrat de serialisation du ticket de pesee (M5, spec-back § 4.0 / § 4.0.bis).
* Jumeau de {@see \App\Tests\Module\Transport\Api\CarrierSerializationContractTest}. * Jumeau du test de contrat M4 CarrierSerializationContractTest (module Transport,
* reference en prose pour ne pas materialiser d'import inter-module).
* *
* Capture le JSON REEL (liste + detail) via un ticket cree par l'API (numerotation * Capture le JSON REEL (liste + detail) via un ticket cree par l'API (numerotation
* serveur reelle) et reverifie les 4 pieges du RETEX M1 transposes au M5 : * serveur reelle) et reverifie les 4 pieges du RETEX M1 transposes au M5 :
@@ -81,6 +82,44 @@ final class WeighingTicketSerializationContractTest extends AbstractWeighingTick
$this->dumpDodIfRequested($list, $detail); $this->dumpDodIfRequested($list, $detail);
} }
/**
* Piege #1 symetrique (spec § 4.0.bis) : sur une contrepartie FOURNISSEUR,
* `supplier` doit sortir en OBJET embarque (supplier:read) et `client` etre
* null (jamais un IRI nu). Le cas Client est couvert ci-dessus ; ce test
* verrouille l'autre branche pour qu'un drift de read-group cote Supplier ne
* passe pas inapercu.
*/
public function testSupplierCounterpartyEmbedsSupplier(): void
{
$site = $this->siteByCode('86');
$http = $this->authManageOnSite($site);
$supplierEntity = $this->seedTestSupplier('Ferraille');
$created = $this->postTicket($http, $this->validSupplierTicketPayload($supplierEntity));
self::assertResponseStatusCodeSame(201);
$createdBody = $created->toArray();
$id = (int) $createdBody['id'];
$number = (string) $createdBody['number'];
$detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
$list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, $id);
self::assertNotNull($row, 'Le ticket fournisseur cree doit apparaitre dans la liste filtree.');
// Liste : supplier embarque en objet, client omis/null (skip_null_values).
self::assertIsArray($row['supplier'], 'supplier doit etre un objet embarque (supplier:read), pas un IRI nu.');
self::assertArrayHasKey('companyName', $row['supplier']);
self::assertNull($row['client'] ?? null);
self::assertSame('FOURNISSEUR', $row['counterpartyType']);
// Detail : meme contrat cote item.
self::assertIsArray($detail['supplier']);
self::assertArrayHasKey('companyName', $detail['supplier']);
self::assertNull($detail['client'] ?? null);
}
/** /**
* DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si WEIGHING_TICKET_DOD_DUMP * DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si WEIGHING_TICKET_DOD_DUMP
* est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis. * est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis.
@@ -31,7 +31,7 @@ final class NetWeightTest extends TestCase
{ {
public function testNetIsFullMinusEmptyWhenBothPresent(): void public function testNetIsFullMinusEmptyWhenBothPresent(): void
{ {
$ticket = (new WeighingTicket()) $ticket = new WeighingTicket()
->setEmptyWeight(7150) ->setEmptyWeight(7150)
->setFullWeight(14300) ->setFullWeight(14300)
; ;
@@ -44,7 +44,7 @@ final class NetWeightTest extends TestCase
public function testNetIsNullWhenFullWeightMissing(): void public function testNetIsNullWhenFullWeightMissing(): void
{ {
$ticket = (new WeighingTicket())->setEmptyWeight(7150); $ticket = new WeighingTicket()->setEmptyWeight(7150);
$this->makeProcessor(isNew: true)->process($ticket, new Post()); $this->makeProcessor(isNew: true)->process($ticket, new Post());
@@ -53,7 +53,7 @@ final class NetWeightTest extends TestCase
public function testNetIsNullWhenEmptyWeightMissing(): void public function testNetIsNullWhenEmptyWeightMissing(): void
{ {
$ticket = (new WeighingTicket())->setFullWeight(14300); $ticket = new WeighingTicket()->setFullWeight(14300);
$this->makeProcessor(isNew: true)->process($ticket, new Post()); $this->makeProcessor(isNew: true)->process($ticket, new Post());
@@ -76,7 +76,7 @@ final class NetWeightTest extends TestCase
*/ */
public function testNetIsRecomputedOnPatch(): void public function testNetIsRecomputedOnPatch(): void
{ {
$ticket = (new WeighingTicket()) $ticket = new WeighingTicket()
->setSite($this->site()) ->setSite($this->site())
->setEmptyWeight(7150) ->setEmptyWeight(7150)
->setFullWeight(20000) ->setFullWeight(20000)
@@ -88,9 +88,11 @@ final class NetWeightTest extends TestCase
} }
/** /**
* Construit le Processor avec des dependances stubbees. `isNew` pilote * Construit le Processor avec des dependances stubbees. `isNew` porte le sens
* EntityManager::contains() : false => creation (POST, attribution site/numero), * metier : true => creation (POST, attribution site/numero), false => entite
* true => entite geree (PATCH, ni site ni numero retouches). * 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 private function makeProcessor(bool $isNew): WeighingTicketProcessor
{ {
@@ -123,7 +125,7 @@ final class NetWeightTest extends TestCase
{ {
// getId() reste null : numberAllocator et dsdAllocator sont stubbes, donc // getId() reste null : numberAllocator et dsdAllocator sont stubbes, donc
// aucune requete reelle ne depend de l'id du site. // aucune requete reelle ne depend de l'id du site.
return (new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233')) return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233')
->setCode('86') ->setCode('86')
; ;
} }