Files
Starseed/tests/Module/Transport/Api/CarrierSerializationContractTest.php
T
tristan 498cef8cc0
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 3m15s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m50s
fix(transport) : embarque le nom de la décharge dans le détail carrier (consultation/modif) (ERP-171)
2026-06-17 16:08:08 +02:00

241 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Transport\Api;
use App\Module\Transport\Domain\Entity\Carrier;
use App\Shared\Domain\Entity\UploadedDocument;
use App\Tests\Module\Commercial\Api\SupplierSerializationContractTest;
use DateTimeImmutable;
/**
* Tests du CONTRAT DE SERIALISATION du repertoire transporteurs (M4, spec-back
* § 4.0 / § 4.0.bis). Jumeau de {@see 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']);
}
// === Decharge (RG-4.02) embarquee en OBJET avec son nom de fichier (ERP-171) ===
public function testDetailEmbedsDischargeDocumentFilename(): void
{
$em = $this->getEm();
// Decharge (UploadedDocument) rattachee a un transporteur certifie AUTRE.
$document = new UploadedDocument(
originalFilename: 'decharge-test.pdf',
storedPath: '2026/06/'.bin2hex(random_bytes(8)).'.pdf',
mimeType: 'application/pdf',
sizeBytes: 1234,
checksum: hash('sha256', 'contenu'),
createdAt: new DateTimeImmutable(),
);
$em->persist($document);
$carrier = new Carrier();
$carrier->setName('AUTRE DISCHARGE CO');
$carrier->setCertificationType('AUTRE');
$carrier->setDischargeDocument($document);
$em->persist($carrier);
$em->flush();
$http = $this->createAdminClient();
$data = $http->request('GET', '/api/carriers/'.$carrier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
// dischargeDocument embarque en OBJET (uploaded_document:reference) avec son
// nom de fichier — sinon le front n'a qu'un IRI nu et affiche un champ vide.
self::assertArrayHasKey('dischargeDocument', $data);
self::assertIsArray($data['dischargeDocument'], 'dischargeDocument doit etre un objet embarque, pas un IRI nu.');
self::assertSame('decharge-test.pdf', $data['dischargeDocument']['originalFilename']);
// Le groupe minimal n'expose PAS les metadonnees internes (storedPath / checksum).
self::assertArrayNotHasKey('storedPath', $data['dischargeDocument']);
self::assertArrayNotHasKey('checksum', $data['dischargeDocument']);
}
// === 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 null|array<string, mixed>
*/
private function memberById(array $collection, int $id): ?array
{
foreach ($collection['member'] ?? [] as $member) {
if (($member['id'] ?? null) === $id) {
return $member;
}
}
return null;
}
}