Files
Starseed/tests/Module/Transport/Api/CarrierPriceApiTest.php
T
Matthieu 6b1e2c2a80 feat(transport) : sous-ressource prix transporteur (ERP-161)
POST /api/carriers/{id}/prices + PATCH/DELETE /api/carrier_prices/{id}
(security transport.carriers.manage) via CarrierPriceProcessor.

RG-4.09->4.11 : coherence de branche CLIENT/FOURNISSEUR (champs requis +
appartenance de l'adresse de livraison au client / de l'adresse d'appro au
fournisseur, sinon 422), nettoyage de la branche opposee (CHECK BDD). Champs
communs obligatoires via Assert\NotBlank + Assert\Choice.

Les contrats Shared ClientAddressInterface / SupplierAddressInterface exposent
desormais getClient() / getSupplier() (canal cross-module, regle n°1) pour la
verification d'appartenance. Colonnes enum du prix whitelistees dans le miroir
Assert\Length (deja bornees par Choice).
2026-06-16 10:42:41 +02:00

277 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Transport\Domain\Entity\CarrierPrice;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Sous-ressource Prix d'un transporteur (spec-back M4 § 4.5, ERP-161).
* POST /api/carriers/{id}/prices, PATCH/DELETE /api/carrier_prices/{id}.
*
* Contrat verifie (RG-4.09→4.11) :
* - branche CLIENT incomplete -> 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();
$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);
}
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();
$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);
}
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 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;
}
}