422 ; * - branche FOURNISSEUR incomplete -> 422 ; * - adresse de livraison etrangere au client -> 422 ; * - adresse d'appro etrangere au fournisseur -> 422 ; * - prix CLIENT / FOURNISSEUR complets -> 201 ; * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). * * @internal */ final class CarrierPriceApiTest extends AbstractCarrierApiTestCase { private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; protected function setUp(): void { parent::setUp(); // Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin // qu'en recette), requis pour les tests de permission (bureau/commerciale). self::bootKernel(); $application = new Application(self::$kernel); $application->setAutoExit(false); $exit = $application->run( new ArrayInput([ 'command' => 'app:seed-rbac', '--with-demo-users' => true, '--password' => self::PWD, ]), new NullOutput(), ); self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); self::ensureKernelShutdown(); } public function testIncompleteClientBranchReturns422(): void { // RG-4.10 : direction CLIENT sans client / adresse / site de depart -> 422. $carrier = $this->seedCarrier('Prix Client Incomplet'); $client = $this->createAdminClient(); $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'direction' => 'CLIENT', 'containerType' => 'BENNE', 'pricingUnit' => 'TONNE', 'price' => '42.50', 'priceState' => 'VALIDE', ], ]); self::assertResponseStatusCodeSame(422); } public function testIncompleteSupplierBranchReturns422(): void { // RG-4.11 : direction FOURNISSEUR sans fournisseur / adresse / site -> 422. $carrier = $this->seedCarrier('Prix Fournisseur Incomplet'); $client = $this->createAdminClient(); $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'direction' => 'FOURNISSEUR', 'containerType' => 'FOND_MOUVANT', 'pricingUnit' => 'FORFAIT', 'price' => '320.00', 'priceState' => 'EN_COURS', ], ]); self::assertResponseStatusCodeSame(422); } public function testForeignClientAddressReturns422(): void { // RG-4.10 : l'adresse de livraison doit appartenir au client choisi. $carrier = $this->seedCarrier('Prix Adresse Etrangere Client'); $addrA = $this->seedClientWithAddress('Client A'); $addrB = $this->seedClientWithAddress('Client B'); $this->getEm()->flush(); $siteId = $this->aSiteId(); $client = $this->createAdminClient(); $response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'direction' => 'CLIENT', 'client' => '/api/clients/'.$addrA->getClient()?->getId(), 'clientDeliveryAddress' => '/api/client_addresses/'.$addrB->getId(), // adresse du client B 'departureSite' => '/api/sites/'.$siteId, 'containerType' => 'BENNE', 'pricingUnit' => 'TONNE', 'price' => '42.50', 'priceState' => 'VALIDE', ], ]); self::assertResponseStatusCodeSame(422); // Faux-vert evite : la 422 doit prouver l'integrite referentielle adresse<->tiers // (violation sur clientDeliveryAddress), pas une autre cause (RG-4.10, ERP-101). self::assertViolationOnPath($response, 'clientDeliveryAddress'); } public function testForeignSupplierAddressReturns422(): void { // RG-4.11 : l'adresse d'appro doit appartenir au fournisseur choisi. $carrier = $this->seedCarrier('Prix Adresse Etrangere Fournisseur'); $addrA = $this->seedSupplierWithAddress('Fournisseur A'); $addrB = $this->seedSupplierWithAddress('Fournisseur B'); $this->getEm()->flush(); $siteId = $this->aSiteId(); $client = $this->createAdminClient(); $response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'direction' => 'FOURNISSEUR', 'supplier' => '/api/suppliers/'.$addrA->getSupplier()?->getId(), 'supplierSupplyAddress' => '/api/supplier_addresses/'.$addrB->getId(), // adresse du fournisseur B 'deliverySite' => '/api/sites/'.$siteId, 'containerType' => 'FOND_MOUVANT', 'pricingUnit' => 'FORFAIT', 'price' => '320.00', 'priceState' => 'EN_COURS', ], ]); self::assertResponseStatusCodeSame(422); self::assertViolationOnPath($response, 'supplierSupplyAddress'); } public function testValidClientPriceIsCreated(): void { $carrier = $this->seedCarrier('Prix Client Valide'); $addr = $this->seedClientWithAddress('Client OK'); $this->getEm()->flush(); $siteId = $this->aSiteId(); $client = $this->createAdminClient(); $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'direction' => 'CLIENT', 'client' => '/api/clients/'.$addr->getClient()?->getId(), 'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(), 'departureSite' => '/api/sites/'.$siteId, 'containerType' => 'BENNE', 'pricingUnit' => 'TONNE', 'price' => '42.50', 'priceState' => 'VALIDE', ], ]); self::assertResponseStatusCodeSame(201); self::assertJsonContains(['direction' => 'CLIENT', 'priceState' => 'VALIDE']); } public function testValidSupplierPriceIsCreated(): void { $carrier = $this->seedCarrier('Prix Fournisseur Valide'); $addr = $this->seedSupplierWithAddress('Fournisseur OK'); $this->getEm()->flush(); $siteId = $this->aSiteId(); $client = $this->createAdminClient(); $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'direction' => 'FOURNISSEUR', 'supplier' => '/api/suppliers/'.$addr->getSupplier()?->getId(), 'supplierSupplyAddress' => '/api/supplier_addresses/'.$addr->getId(), 'deliverySite' => '/api/sites/'.$siteId, 'containerType' => 'FOND_MOUVANT', 'pricingUnit' => 'FORFAIT', 'price' => '320.00', 'priceState' => 'EN_COURS', ], ]); self::assertResponseStatusCodeSame(201); self::assertJsonContains(['direction' => 'FOURNISSEUR', 'priceState' => 'EN_COURS']); } public function testNegativePriceReturns422(): void { // Le prix porte un Assert\PositiveOrZero : une valeur negative -> 422 sur `price` // (la branche CLIENT est par ailleurs complete pour isoler la cause). $carrier = $this->seedCarrier('Prix Negatif'); $addr = $this->seedClientWithAddress('Client Prix Negatif'); $this->getEm()->flush(); $siteId = $this->aSiteId(); $client = $this->createAdminClient(); $response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'direction' => 'CLIENT', 'client' => '/api/clients/'.$addr->getClient()?->getId(), 'clientDeliveryAddress' => '/api/client_addresses/'.$addr->getId(), 'departureSite' => '/api/sites/'.$siteId, 'containerType' => 'BENNE', 'pricingUnit' => 'TONNE', 'price' => '-5.00', 'priceState' => 'VALIDE', ], ]); self::assertResponseStatusCodeSame(422); self::assertViolationOnPath($response, 'price'); } public function testPostPriceOnUnknownCarrierReturns404(): void { // Parent introuvable (read:false) -> 404 explicite du processor (linkParent // s'execute avant validateBranch). Le payload porte les scalaires NotBlank // (containerType/pricingUnit/price/priceState) pour passer la validation // d'entite et atteindre le processor, ou le 404 prime. $client = $this->createAdminClient(); $client->request('POST', '/api/carriers/999999/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'direction' => 'CLIENT', 'containerType' => 'BENNE', 'pricingUnit' => 'TONNE', 'price' => '42.50', 'priceState' => 'VALIDE', ], ]); self::assertResponseStatusCodeSame(404); } public function testPatchAndDeleteSucceedWithManage(): void { $price = $this->seedClientPrice('Patch Delete'); $client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2) // PATCH (manage) -> 200 $client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['priceState' => 'NON_VALIDE'], ]); self::assertResponseStatusCodeSame(200); self::assertJsonContains(['priceState' => 'NON_VALIDE']); // DELETE (manage) -> 204 $client->request('DELETE', '/api/carrier_prices/'.$price->getId()); self::assertResponseStatusCodeSame(204); } public function testWriteForbiddenWithoutManage(): void { $price = $this->seedClientPrice('Forbidden'); $carrier = $price->getCarrier(); self::assertNotNull($carrier); $client = $this->authenticatedClient('commerciale', self::PWD); // view seul $client->request('POST', '/api/carriers/'.$carrier->getId().'/prices', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['direction' => 'CLIENT'], ]); self::assertResponseStatusCodeSame(403); $client->request('PATCH', '/api/carrier_prices/'.$price->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['priceState' => 'VALIDE'], ]); self::assertResponseStatusCodeSame(403); $client->request('DELETE', '/api/carrier_prices/'.$price->getId()); self::assertResponseStatusCodeSame(403); } /** Id d'un site fixture (adresse de depart / livraison des prix). */ private function aSiteId(): int { $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); self::assertNotNull($site, 'Un site fixture est requis (SitesFixtures).'); $id = $site->getId(); self::assertNotNull($id); return $id; } /** * Seede un transporteur + un prix CLIENT complet rattache (pour les tests * PATCH / DELETE). Passe par l'EM directement (le flux d'ecriture est teste * via l'API ailleurs). */ private function seedClientPrice(string $name): CarrierPrice { $em = $this->getEm(); $carrier = $this->seedCarrier($name); /** @var ClientAddress $addr */ $addr = $this->seedClientWithAddress($name); $price = new CarrierPrice(); $price->setCarrier($carrier); $price->setDirection('CLIENT'); $price->setClient($addr->getClient()); $price->setClientDeliveryAddress($addr); $price->setDepartureSite($em->getRepository(Site::class)->findOneBy([])); $price->setContainerType('BENNE'); $price->setPricingUnit('TONNE'); $price->setPrice('42.50'); $price->setPriceState('VALIDE'); $carrier->addPrice($price); $em->persist($price); $em->flush(); return $price; } }