422 (au moins 1 champ requis) ; * - RG-4.08 : 1 seul champ rempli -> 201 ; * - RG-4.08 : 3 telephones (tableau `phones`) -> 422 (max 2) ; * - mapping `phones[]` -> phonePrimary / phoneSecondary + normalisation (RG-4.13) ; * - PATCH / DELETE OK avec transport.carriers.manage, 403 sans (view seul). * * @internal */ final class CarrierContactApiTest extends AbstractCarrierApiTestCase { private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; protected function setUp(): void { parent::setUp(); // Seed idempotent des roles + matrice § 5.2 + comptes demo (meme chemin // qu'en recette), requis pour les tests de permission (bureau/commerciale). self::bootKernel(); $application = new Application(self::$kernel); $application->setAutoExit(false); $exit = $application->run( new ArrayInput([ 'command' => 'app:seed-rbac', '--with-demo-users' => true, '--password' => self::PWD, ]), new NullOutput(), ); self::assertSame(0, $exit, 'app:seed-rbac a echoue (permissions transport.carriers.* synchronisees ?).'); self::ensureKernelShutdown(); } public function testEmptyContactReturns422(): void { // RG-4.08 : aucun champ rempli -> 422 (garde Processor, double du CHECK BDD). $carrier = $this->seedCarrier('Contact Vide'); $client = $this->createAdminClient(); $response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => [], ]); self::assertResponseStatusCodeSame(422); // RG-4.08 : la violation est rattachee a `firstName` (mapping inline ERP-101). self::assertViolationOnPath($response, 'firstName'); } public function testSingleFieldContactIsCreated(): void { // RG-4.08 : un seul champ suffit a valider le bloc. $carrier = $this->seedCarrier('Contact Mono'); $client = $this->createAdminClient(); $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['lastName' => 'martin'], ]); self::assertResponseStatusCodeSame(201); // RG-4.13 : nom capitalise serveur. self::assertJsonContains(['lastName' => 'Martin']); } public function testThirdPhoneReturns422(): void { // RG-4.08 : max 2 telephones. Le contrat d'ecriture accepte un tableau // `phones` (liste dynamique cote front « x1, +1 possible, max 2 ») ; un 3e // numero -> 422 rattachee au champ `phones`. $carrier = $this->seedCarrier('Contact Trois Tel'); $client = $this->createAdminClient(); $response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'firstName' => 'Jean', 'phones' => ['0611111111', '0622222222', '0633333333'], ], ]); self::assertResponseStatusCodeSame(422); // Le max-2 cible le champ virtuel `phones` (mapping inline ERP-101). self::assertViolationOnPath($response, 'phones'); } public function testInvalidEmailReturns422(): void { // L'email du contact porte un Assert\Email (nouvelle contrainte M4) : une // adresse mal formee -> 422 ciblee sur `email`. $carrier = $this->seedCarrier('Contact Email Invalide'); $client = $this->createAdminClient(); $response = $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['lastName' => 'Durand', 'email' => 'pas-un-email'], ]); self::assertResponseStatusCodeSame(422); self::assertViolationOnPath($response, 'email'); } public function testPostContactOnUnknownCarrierReturns404(): void { // Parent introuvable (read:false) -> 404 explicite du processor. $client = $this->createAdminClient(); $client->request('POST', '/api/carriers/999999/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['lastName' => 'Martin'], ]); self::assertResponseStatusCodeSame(404); } public function testPhonesAreMappedAndNormalized(): void { // Mapping `phones[0]` -> phonePrimary, `phones[1]` -> phoneSecondary + // normalisation RG-4.13 (chiffres uniquement). $carrier = $this->seedCarrier('Contact Deux Tel'); $client = $this->createAdminClient(); $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'lastName' => 'Dupont', 'phones' => ['06.11.11.11.11', '06 22 22 22 22'], ], ]); self::assertResponseStatusCodeSame(201); self::assertJsonContains([ 'phonePrimary' => '0611111111', 'phoneSecondary' => '0622222222', ]); } public function testPatchAndDeleteSucceedWithManage(): void { $contact = $this->seedContact('Patch Delete'); $client = $this->authenticatedClient('bureau', self::PWD); // manage (matrice § 5.2) // PATCH (manage) -> 200 $client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['jobTitle' => 'Directeur'], ]); self::assertResponseStatusCodeSame(200); // DELETE (manage) -> 204 $client->request('DELETE', '/api/carrier_contacts/'.$contact->getId()); self::assertResponseStatusCodeSame(204); } public function testWriteForbiddenWithoutManage(): void { $contact = $this->seedContact('Forbidden'); $carrier = $contact->getCarrier(); self::assertNotNull($carrier); $client = $this->authenticatedClient('commerciale', self::PWD); // view seul $client->request('POST', '/api/carriers/'.$carrier->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['lastName' => 'Bernard'], ]); self::assertResponseStatusCodeSame(403); $client->request('PATCH', '/api/carrier_contacts/'.$contact->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['jobTitle' => 'Chef'], ]); self::assertResponseStatusCodeSame(403); $client->request('DELETE', '/api/carrier_contacts/'.$contact->getId()); self::assertResponseStatusCodeSame(403); } /** * Seede un transporteur + un contact rattache (pour les tests PATCH/DELETE). */ private function seedContact(string $name): CarrierContact { $em = $this->getEm(); $carrier = $this->seedCarrier($name); $contact = new CarrierContact(); $contact->setCarrier($carrier); $contact->setLastName('Martin'); $contact->setPhonePrimary('0612345678'); $carrier->addContact($contact); $em->persist($contact); $em->flush(); return $contact; } }