From 47fdbc43ff72891280b1ea516a62452e010e5360 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 18 Jun 2026 14:26:33 +0200 Subject: [PATCH] =?UTF-8?q?test(logistique)=20:=20durcissement=20tests=20M?= =?UTF-8?q?5=20=E2=80=94=20embed=20FOURNISSEUR=20symetrique=20+=20property?= =?UTF-8?q?Path=20422=20(ERP-187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../Api/AbstractWeighingTicketApiTestCase.php | 64 ++++++++++++++++++- .../Api/WeighbridgeReadingApiTest.php | 31 +++++++-- ...eighingTicketSerializationContractTest.php | 41 +++++++++++- .../State/Processor/NetWeightTest.php | 18 +++--- 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php b/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php index e16b1c6..42c3039 100644 --- a/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php +++ b/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php @@ -6,6 +6,7 @@ namespace App\Tests\Module\Logistique\Api; use ApiPlatform\Symfony\Bundle\Test\Client; 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\User; 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). */ 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 { $em = $this->getEm(); - // Tickets referencant un Client de test d'abord (FK client_id RESTRICT) : - // purge DBAL brute pour liberer les Client avant de les supprimer. + // Tickets referencant un Client OU un Supplier de test d'abord (FK + // 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( 'DELETE FROM weighing_ticket WHERE client_id IN (SELECT id FROM client WHERE company_name LIKE :p)', ['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') ->setParameter('p', self::TEST_CLIENT_PREFIX.'%')->execute() ; @@ -138,6 +151,28 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase 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 * 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 + */ + 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 * l'appelant). @@ -177,7 +235,7 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase * * @param array $collection * - * @return array|null + * @return null|array */ protected function memberById(array $collection, int $id): ?array { diff --git a/tests/Module/Logistique/Api/WeighbridgeReadingApiTest.php b/tests/Module/Logistique/Api/WeighbridgeReadingApiTest.php index 06ab388..bb04ed4 100644 --- a/tests/Module/Logistique/Api/WeighbridgeReadingApiTest.php +++ b/tests/Module/Logistique/Api/WeighbridgeReadingApiTest.php @@ -29,9 +29,11 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase $em = $this->getEm(); $em->getConnection()->executeStatement('DELETE FROM weighbridge_dsd_counter'); $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') - ->setParameter('p', 'test_%')->execute(); + ->setParameter('p', 'test_%')->execute() + ; parent::tearDown(); } @@ -95,24 +97,45 @@ final class WeighbridgeReadingApiTest extends AbstractApiTestCase { $client = $this->manageClientWithCurrentSite(); - $client->request('POST', '/api/weighbridge_readings', [ + $response = $client->request('POST', '/api/weighbridge_readings', [ 'headers' => ['Content-Type' => 'application/ld+json'], '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::assertViolationOnPath($response, 'mode'); } public function testManualWeighingRequiresWeight(): void { $client = $this->manageClientWithCurrentSite(); - $client->request('POST', '/api/weighbridge_readings', [ + $response = $client->request('POST', '/api/weighbridge_readings', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['mode' => 'MANUAL'], ]); + // Garde-fou ERP-101 : la 422 doit cibler `weight` (Callback validateManualWeight). 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)), + ); } /** diff --git a/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php b/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php index 7ae6ebe..c459d18 100644 --- a/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php +++ b/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php @@ -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). - * 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 * 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); } + /** + * 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 * est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis. diff --git a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/NetWeightTest.php b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/NetWeightTest.php index 10279f6..6f87ce7 100644 --- a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/NetWeightTest.php +++ b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/NetWeightTest.php @@ -31,7 +31,7 @@ final class NetWeightTest extends TestCase { public function testNetIsFullMinusEmptyWhenBothPresent(): void { - $ticket = (new WeighingTicket()) + $ticket = new WeighingTicket() ->setEmptyWeight(7150) ->setFullWeight(14300) ; @@ -44,7 +44,7 @@ final class NetWeightTest extends TestCase public function testNetIsNullWhenFullWeightMissing(): void { - $ticket = (new WeighingTicket())->setEmptyWeight(7150); + $ticket = new WeighingTicket()->setEmptyWeight(7150); $this->makeProcessor(isNew: true)->process($ticket, new Post()); @@ -53,7 +53,7 @@ final class NetWeightTest extends TestCase public function testNetIsNullWhenEmptyWeightMissing(): void { - $ticket = (new WeighingTicket())->setFullWeight(14300); + $ticket = new WeighingTicket()->setFullWeight(14300); $this->makeProcessor(isNew: true)->process($ticket, new Post()); @@ -76,7 +76,7 @@ final class NetWeightTest extends TestCase */ public function testNetIsRecomputedOnPatch(): void { - $ticket = (new WeighingTicket()) + $ticket = new WeighingTicket() ->setSite($this->site()) ->setEmptyWeight(7150) ->setFullWeight(20000) @@ -88,9 +88,11 @@ final class NetWeightTest extends TestCase } /** - * 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). + * 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 { @@ -123,7 +125,7 @@ final class NetWeightTest extends TestCase { // 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')) + return new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233') ->setCode('86') ; }