Files
Starseed/tests/Module/Transport/Api/CarrierSerializationContractTest.php
T
Matthieu d9313dbec8 feat(transport) : schéma + entités Carrier + contrat lecture (ERP-155/157)
Schéma BDD du répertoire transporteurs (M4) + entités + contrat de lecture
(liste + détail), socle du front.

- Migration Version20260615150000 : tables carrier / carrier_address /
  carrier_contact / carrier_price (FK cross-module, CHECK enum, index partiel
  uq_carrier_name_active, COMMENT ON COLUMN). uploaded_document et
  qualimat_carrier réutilisées (non recréées).
- Entités Carrier* (#[Auditable], Timestampable/Blamable) + ApiResource
  LECTURE seule (GetCollection + Get via CarrierProvider, anti-N+1, exclusion
  archivés + ?includeArchived). Écriture (POST/PATCH + Processor) reportée WT4+.
- QualimatCarrier : mapping ORM lecture seule sur la table référentielle
  existante (sortie du schema_filter, mapping aligné DDL ERP-39, schema:update
  no-op) + endpoint de recherche read-only (§ 4.7).
- Relations cross-module des prix (Client/Supplier/adresses) via contrats
  Shared (ClientInterface, SupplierInterface, ClientAddressInterface,
  SupplierAddressInterface) + resolve_target_entities — sans import inter-module
  (règle n°1). Ajout du groupe supplier_address:read aux champs de
  SupplierAddress pour l'embed.
- Garde-fous : ColumnCommentsCatalog (carrier* + qualimat_carrier), makefile
  test-db-setup (index partiel carrier), i18n audit (transport_carrier*),
  EntitiesAreTimestampableBlamableTest (QualimatCarrier whitelisté).
- CarrierSerializationContractTest : contrat JSON liste + détail vérifié
  (embeds objet, booléens, enveloppe Hydra) ; JSON réel capturé dans
  spec-back § 4.0.bis.

make db-reset OK, make test vert (731), make nuxt-test vert (480),
php-cs-fixer OK.
2026-06-15 19:15:12 +02:00

199 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
/**
* Tests du CONTRAT DE SERIALISATION du repertoire transporteurs (M4, spec-back
* § 4.0 / § 4.0.bis). Jumeau de {@see \App\Tests\Module\Commercial\Api\SupplierSerializationContractTest}.
* Reverifie sur le JSON REEL les pieges silencieux du M1 transposes au M4 :
* - #1/#2 : relations embarquees en OBJET (pas IRI nu) — qualimatCarrier, et au
* detail prices[].client / .supplier / .departureSite / .deliverySite.
* - #3 : booleens isArchived / isChartered presents dans le JSON (Groups +
* SerializedName sur le getter).
* - enveloppe AP4 (member/totalItems/view sans prefixe hydra:) + exclusion des
* archives par defaut, ?includeArchived=true les reintegre.
*
* REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations.
*
* @internal
*/
final class CarrierSerializationContractTest extends AbstractCarrierApiTestCase
{
// === Enveloppe AP4 + exclusion des archives (§ 4.1) ===
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
{
$http = $this->createAdminClient();
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
$this->seedCarrier($token.' Active');
$this->seedCarrier($token.' Archived', true);
$default = $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $default);
self::assertArrayHasKey('totalItems', $default);
self::assertArrayNotHasKey('hydra:member', $default);
self::assertArrayNotHasKey('hydra:totalItems', $default);
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
$all = $http->request('GET', '/api/carriers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertSame(2, $all['totalItems']);
$paged = $http->request('GET', '/api/carriers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('view', $paged);
self::assertArrayNotHasKey('hydra:view', $paged);
}
// === #3 — Booleens presents (isArchived) + embed qualimatCarrier en LISTE ===
public function testListExposesIsArchivedAndEmbeddedQualimat(): void
{
$token = 'List'.substr(bin2hex(random_bytes(3)), 0, 6);
$carrier = $this->seedCompleteCarrier($token);
$http = $this->createAdminClient();
$list = $http->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$row = $this->memberById($list, (int) $carrier->getId());
self::assertNotNull($row, 'Le transporteur seede doit apparaitre dans la liste filtree.');
// Boolean trap (#3) : cle presente et typee bool.
self::assertArrayHasKey('isArchived', $row);
self::assertFalse($row['isArchived']);
// qualimatCarrier embarque en OBJET (statut + date de validite — RG-4.04),
// pas un IRI nu (#1/#2).
self::assertArrayHasKey('qualimatCarrier', $row);
self::assertIsArray($row['qualimatCarrier'], 'qualimatCarrier doit etre un objet embarque, pas un IRI nu.');
self::assertArrayHasKey('status', $row['qualimatCarrier']);
self::assertArrayHasKey('validityDate', $row['qualimatCarrier']);
// updatedAt (default:read) expose pour la colonne « Derniere activite ».
self::assertArrayHasKey('updatedAt', $row);
}
// === Detail : sous-collections embarquees + booleens ===
public function testDetailEmbedsSubCollectionsAndBooleans(): void
{
$carrier = $this->seedCompleteCarrier('Detail Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('isArchived', $data);
self::assertArrayHasKey('isChartered', $data);
self::assertFalse($data['isArchived']);
self::assertNotEmpty($data['addresses']);
self::assertSame('Poitiers', $data['addresses'][0]['city']);
self::assertNotEmpty($data['contacts']);
self::assertSame('Marie', $data['contacts'][0]['firstName']);
self::assertNotEmpty($data['prices']);
self::assertGreaterThanOrEqual(2, count($data['prices']));
}
// === #1/#2 — prices[] : client / supplier / sites embarques en OBJET ===
public function testPriceCrossModuleRelationsAreEmbeddedObjects(): void
{
$carrier = $this->seedCompleteCarrier('Price Embed Co');
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
$byDirection = [];
foreach ($data['prices'] as $price) {
$byDirection[$price['direction']] = $price;
}
self::assertArrayHasKey('CLIENT', $byDirection);
self::assertArrayHasKey('FOURNISSEUR', $byDirection);
// Branche CLIENT : client + adresse + site de depart en OBJET (pas IRI).
$clientPrice = $byDirection['CLIENT'];
self::assertIsArray($clientPrice['client'], 'prices[].client doit etre un objet embarque (client:read), pas un IRI nu.');
self::assertArrayHasKey('companyName', $clientPrice['client']);
self::assertIsArray($clientPrice['clientDeliveryAddress']);
self::assertArrayHasKey('city', $clientPrice['clientDeliveryAddress'], 'L\'adresse client doit embarquer ses champs (client_address:read).');
self::assertIsArray($clientPrice['departureSite']);
self::assertArrayHasKey('name', $clientPrice['departureSite']);
// Branche FOURNISSEUR : supplier + adresse + site de livraison en OBJET.
$supplierPrice = $byDirection['FOURNISSEUR'];
self::assertIsArray($supplierPrice['supplier'], 'prices[].supplier doit etre un objet embarque (supplier:read), pas un IRI nu.');
self::assertArrayHasKey('companyName', $supplierPrice['supplier']);
self::assertIsArray($supplierPrice['supplierSupplyAddress']);
self::assertArrayHasKey('city', $supplierPrice['supplierSupplyAddress'], 'L\'adresse fournisseur doit embarquer ses champs (supplier_address:read).');
self::assertIsArray($supplierPrice['deliverySite']);
}
// === RBAC : 403 sans la permission view ===
public function testForbiddenWithoutViewPermission(): void
{
$carrier = $this->seedCarrier('Rbac Co');
// user-nothing : aucune permission transport.carriers.*.
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('GET', '/api/carriers', ['headers' => ['Accept' => self::LD]]);
self::assertSame(403, $http->getResponse()->getStatusCode());
$http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertSame(403, $http->getResponse()->getStatusCode());
}
/**
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail) pour
* les coller dans la spec avant de lancer les tickets front. Le test asserte
* la forme ; si CARRIER_DOD_DUMP est positionnee, ecrit les corps sous /tmp.
*/
public function testDodReferenceJsonShape(): void
{
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
$carrier = $this->seedCompleteCarrier($token);
$id = (int) $carrier->getId();
$admin = $this->createAdminClient();
$list = $admin->request('GET', '/api/carriers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
$detail = $admin->request('GET', '/api/carriers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
self::assertArrayHasKey('member', $list);
self::assertArrayHasKey('qualimatCarrier', $detail);
self::assertArrayHasKey('addresses', $detail);
self::assertArrayHasKey('contacts', $detail);
self::assertArrayHasKey('prices', $detail);
if (false !== getenv('CARRIER_DOD_DUMP')) {
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
file_put_contents('/tmp/carrier-dod-list.json', json_encode($list, $flags));
file_put_contents('/tmp/carrier-dod-detail.json', json_encode($detail, $flags));
}
}
/**
* Retrouve un membre de la collection par son id (liste filtree).
*
* @param array<string, mixed> $collection
*
* @return array<string, mixed>|null
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}