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 ab15452459
commit 1bb6334baa
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 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<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
* l'appelant).
@@ -177,7 +235,7 @@ abstract class AbstractWeighingTicketApiTestCase extends AbstractApiTestCase
*
* @param array<string, mixed> $collection
*
* @return array<string, mixed>|null
* @return null|array<string, mixed>
*/
protected function memberById(array $collection, int $id): ?array
{
@@ -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)),
);
}
/**
@@ -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.
@@ -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')
;
}