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)), ); } }