13d4a08bc9
Ecriture du formulaire principal transporteur (M4, WT4) : POST/PATCH via CarrierProcessor + CarrierFieldNormalizer, contraintes conditionnelles sur l'entite Carrier. - RG-4.01 : POST qualimatCarrier -> certificationType=QUALIMAT + FK persistee ; cas LIOT (name=LIOT) -> certification non requise, liotPlates accepte. - RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 (Assert\Callback). - RG-4.03 : isChartered=true sans indexationRate/containerType/volumeM3 -> 422. - RG-4.12 : doublon de nom (parmi actifs) -> 409 (index partiel uq_carrier_name_active). - RG-4.13 : normalisation serveur (name UPPER, liotPlates ;-split/trim/UPPER) + methodes personne/telephone/email pour les sous-ressources Contact (WT7). - RG-4.14 : PATCH isArchived exige transport.carriers.archive (Admin seul), mode strict -> 403 + 422 si autre champ ; restauration en conflit -> 409. Operations Post/Patch ajoutees a l'ApiResource (lecture posee au WT3 conservee). RG conditionnelles portees par validateMainFormConsistency (Assert\Callback + ->atPath()) pour un propertyPath mappable inline (useFormErrors, ERP-101). certificationType / containerType whitelistes dans EXCLUDED_LENGTH_MIRROR (Choice borne deja les valeurs, miroir SupplierAddress::addressType). Tests : CarrierWriteApiTest (RG-4.01->4.03/4.12->4.14), CarrierRBACMatrixTest (matrice bureau/compta/commerciale/usine), CarrierArchiveTest (409 restauration), CarrierFieldNormalizerTest (RG-4.13). make test vert (750).
241 lines
9.1 KiB
PHP
241 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Transport\Api;
|
|
|
|
/**
|
|
* Ecriture du formulaire principal transporteur (M4, WT4 — ERP-158). Couvre les
|
|
* RG du CarrierProcessor + contraintes conditionnelles : RG-4.01 (QUALIMAT + cas
|
|
* LIOT), RG-4.02 (AUTRE -> decharge), RG-4.03 (affrete -> indexation/benne/volume),
|
|
* RG-4.12 (doublon de nom -> 409), RG-4.13 (normalisation), RG-4.14 (archivage +
|
|
* mode strict). Jumeau des SupplierApiTest / SupplierPatchStrictTest (M2).
|
|
*
|
|
* @internal
|
|
*/
|
|
final class CarrierWriteApiTest extends AbstractCarrierApiTestCase
|
|
{
|
|
/**
|
|
* RG-4.01 : POST avec qualimatCarrier -> certificationType=QUALIMAT accepte et
|
|
* FK persistee (verifiee au detail, qualimatCarrier embarque).
|
|
*/
|
|
public function testPostQualimatPersistsCertificationAndForeignKey(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$qualimat = $this->seedQualimatCarrier('Transports Grelillier');
|
|
|
|
$created = $client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => [
|
|
'name' => 'Transports Grelillier',
|
|
'qualimatCarrier' => '/api/qualimat_carriers/'.$qualimat->getId(),
|
|
'certificationType' => 'QUALIMAT',
|
|
'isChartered' => false,
|
|
],
|
|
])->toArray();
|
|
|
|
self::assertResponseStatusCodeSame(201);
|
|
self::assertSame('QUALIMAT', $created['certificationType']);
|
|
|
|
$detail = $client->request('GET', $created['@id'], ['headers' => ['Accept' => self::LD]])->toArray();
|
|
self::assertIsArray($detail['qualimatCarrier']);
|
|
self::assertSame((int) $qualimat->getId(), (int) $detail['qualimatCarrier']['id']);
|
|
}
|
|
|
|
/**
|
|
* RG-4.01 (cas LIOT) : nom = LIOT -> certificationType non requis (champ masque)
|
|
* et liotPlates accepte (et normalise, RG-4.13).
|
|
*/
|
|
public function testPostLiotAcceptsPlatesWithoutCertification(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
|
|
$created = $client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => [
|
|
'name' => 'LIOT',
|
|
'liotPlates' => 'ab-123-cd ; ef-456-gh',
|
|
'isChartered' => false,
|
|
],
|
|
])->toArray();
|
|
|
|
self::assertResponseStatusCodeSame(201);
|
|
self::assertNull($created['certificationType']);
|
|
self::assertSame('AB-123-CD; EF-456-GH', $created['liotPlates']);
|
|
}
|
|
|
|
/**
|
|
* RG-4.01 : hors cas LIOT, l'absence de certification est rejetee (422 cible
|
|
* sur certificationType).
|
|
*/
|
|
public function testPostWithoutCertificationOutsideLiotIsRejected(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
|
|
$response = $client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => ['name' => 'Sans Certif', 'isChartered' => false],
|
|
]);
|
|
|
|
self::assertResponseStatusCodeSame(422);
|
|
self::assertViolationOnPath($response, 'certificationType');
|
|
}
|
|
|
|
/**
|
|
* RG-4.02 : certificationType=AUTRE sans dischargeDocument -> 422 cible ; une
|
|
* certification != AUTRE sans decharge passe (201).
|
|
*/
|
|
public function testAutreCertificationRequiresDischarge(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
|
|
$response = $client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => ['name' => 'Sans Decharge', 'certificationType' => 'AUTRE', 'isChartered' => false],
|
|
]);
|
|
self::assertResponseStatusCodeSame(422);
|
|
self::assertViolationOnPath($response, 'dischargeDocument');
|
|
|
|
// Certification != AUTRE : pas de decharge requise.
|
|
$client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => ['name' => 'Avec GmpPlus', 'certificationType' => 'GMP_PLUS', 'isChartered' => false],
|
|
]);
|
|
self::assertResponseStatusCodeSame(201);
|
|
}
|
|
|
|
/**
|
|
* RG-4.03 : isChartered=true sans indexationRate / containerType / volumeM3 ->
|
|
* 422 (violations ciblees) ; complet -> 201.
|
|
*/
|
|
public function testCharteredRequiresIndexationContainerAndVolume(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
|
|
$response = $client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => ['name' => 'Affrete Incomplet', 'certificationType' => 'GMP_PLUS', 'isChartered' => true],
|
|
]);
|
|
self::assertResponseStatusCodeSame(422);
|
|
self::assertViolationOnPath($response, 'indexationRate');
|
|
self::assertViolationOnPath($response, 'containerType');
|
|
self::assertViolationOnPath($response, 'volumeM3');
|
|
|
|
$client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => [
|
|
'name' => 'Affrete Complet',
|
|
'certificationType' => 'GMP_PLUS',
|
|
'isChartered' => true,
|
|
'indexationRate' => '5.00',
|
|
'containerType' => 'BENNE',
|
|
'volumeM3' => '90.00',
|
|
],
|
|
]);
|
|
self::assertResponseStatusCodeSame(201);
|
|
}
|
|
|
|
/**
|
|
* RG-4.12 : nom deja pris (parmi actifs) -> 409. Le meme nom redevient
|
|
* disponible apres archivage de l'ancien -> 201.
|
|
*/
|
|
public function testDuplicateNameReturns409AndIsFreedAfterArchive(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$existing = $this->seedCarrier('Doublon Co');
|
|
|
|
$client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => $this->validMainPayload('Doublon Co'),
|
|
]);
|
|
self::assertResponseStatusCodeSame(409);
|
|
|
|
// Archivage de l'ancien -> le nom se libere (index partiel sur actifs).
|
|
$client->request('PATCH', '/api/carriers/'.$existing->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['isArchived' => true],
|
|
]);
|
|
self::assertResponseStatusCodeSame(200);
|
|
|
|
$client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => $this->validMainPayload('Doublon Co'),
|
|
]);
|
|
self::assertResponseStatusCodeSame(201);
|
|
}
|
|
|
|
/**
|
|
* RG-4.13 : le nom est persiste en MAJUSCULES (normalisation serveur).
|
|
*/
|
|
public function testNameIsUpperCasedOnPersist(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
|
|
$created = $client->request('POST', '/api/carriers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => $this->validMainPayload('transports x'),
|
|
])->toArray();
|
|
|
|
self::assertResponseStatusCodeSame(201);
|
|
self::assertSame('TRANSPORTS X', $created['name']);
|
|
}
|
|
|
|
/**
|
|
* RG-4.14 : PATCH isArchived=true par Admin -> 200 + archivedAt rempli ;
|
|
* restauration -> archivedAt remis a null.
|
|
*/
|
|
public function testAdminArchiveSetsArchivedAtAndRestoreClearsIt(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$carrier = $this->seedCarrier('A Archiver');
|
|
|
|
$archived = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['isArchived' => true],
|
|
])->toArray();
|
|
self::assertResponseStatusCodeSame(200);
|
|
self::assertTrue($archived['isArchived']);
|
|
self::assertNotNull($archived['archivedAt']);
|
|
|
|
$restored = $client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['isArchived' => false],
|
|
])->toArray();
|
|
self::assertResponseStatusCodeSame(200);
|
|
self::assertFalse($restored['isArchived']);
|
|
self::assertNull($restored['archivedAt']);
|
|
}
|
|
|
|
/**
|
|
* RG-4.14 (mode strict) : une requete d'archivage ne peut modifier aucun autre
|
|
* champ ecrivable -> 422.
|
|
*/
|
|
public function testArchiveRequestMixingOtherFieldIsRejected(): void
|
|
{
|
|
$client = $this->createAdminClient();
|
|
$carrier = $this->seedCarrier('Strict Co');
|
|
|
|
$client->request('PATCH', '/api/carriers/'.$carrier->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['isArchived' => true, 'name' => 'Renamed While Archiving'],
|
|
]);
|
|
self::assertResponseStatusCodeSame(422);
|
|
}
|
|
|
|
/**
|
|
* Verifie qu'une violation 422 cible bien la propriete attendue (propertyPath),
|
|
* gage du mapping inline front (useFormErrors, ERP-101).
|
|
*/
|
|
private function assertViolationOnPath(object $response, string $path): void
|
|
{
|
|
/** @var \Symfony\Contracts\HttpClient\ResponseInterface $response */
|
|
$paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath');
|
|
|
|
self::assertContains(
|
|
$path,
|
|
$paths,
|
|
sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)),
|
|
);
|
|
}
|
|
}
|