409) et RG-1.14 (DELETE * dernier contact -> 409), plus le gating comptable (POST/PATCH/DELETE de * client_ribs sans accounting.manage -> 403). * * @internal */ final class ClientSubResourceApiTest extends AbstractCommercialApiTestCase { private const string LD = 'application/ld+json'; private const string MERGE = 'application/merge-patch+json'; private const string VALID_IBAN = 'FR1420041010050500013M02606'; private const string VALID_BIC = 'BNPAFRPPXXX'; // === Contacts === public function testPostContactNormalizesFields(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Contact Host'); $data = $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'firstName' => 'JEAN', 'lastName' => 'dupont', 'phonePrimary' => '06.12.34.56.78', 'email' => 'Jean.DUPONT@ACME.FR', ], ])->toArray(); self::assertResponseStatusCodeSame(201); // RG-1.19 / 1.20 / 1.21 self::assertSame('Jean', $data['firstName']); self::assertSame('Dupont', $data['lastName']); self::assertSame('0612345678', $data['phonePrimary']); self::assertSame('jean.dupont@acme.fr', $data['email']); } public function testPostContactWithoutNameReturns422(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Contact No Name'); $client->request('POST', '/api/clients/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['jobTitle' => 'Directeur'], ]); // RG-1.05 self::assertResponseStatusCodeSame(422); } public function testPatchContactNormalizes(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Contact Patch'); $contact = $this->seedContact($seed, 'Paul'); $data = $client->request('PATCH', '/api/client_contacts/'.$contact->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['lastName' => 'martin'], ])->toArray(); self::assertResponseStatusCodeSame(200); self::assertSame('Martin', $data['lastName']); } public function testDeleteContactWhenSeveralReturns204(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Contact Multi'); $this->seedContact($seed, 'Premier'); $second = $this->seedContact($seed, 'Second'); $client->request('DELETE', '/api/client_contacts/'.$second->getId()); self::assertResponseStatusCodeSame(204); } public function testDeleteLastContactReturns409(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Contact Solo'); $only = $this->seedContact($seed, 'Unique'); $client->request('DELETE', '/api/client_contacts/'.$only->getId()); // RG-1.14 self::assertResponseStatusCodeSame(409); } // === Adresses === public function testPostAddressNormalizesBillingEmail(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Host'); $siteIri = $this->firstSiteIri(); $data = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isBilling' => true, 'billingEmail' => 'Facturation@ACME.FR', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$siteIri], ], ])->toArray(); self::assertResponseStatusCodeSame(201); // RG-1.21 self::assertSame('facturation@acme.fr', $data['billingEmail']); } public function testPostAddressWithoutSiteReturns422(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Address No Site'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [], ], ]); // RG-1.10 (Assert\Count min 1) self::assertResponseStatusCodeSame(422); } public function testPostAddressWithInvalidPostalCodeReturns422(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Bad CP'); $siteIri = $this->firstSiteIri(); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'postalCode' => '123', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$siteIri], ], ]); // RG-1.09 (Assert\Regex ^[0-9]{4,5}$) self::assertResponseStatusCodeSame(422); } // === RIBs === public function testPostRibByAdminReturns201(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Rib Host'); $data = $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'label' => 'Compte principal', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN, ], ])->toArray(); self::assertResponseStatusCodeSame(201); self::assertSame('Compte principal', $data['label']); } public function testPostRibWithInvalidIbanReturns422(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Rib Bad Iban'); $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN', ], ]); // Assert\Iban self::assertResponseStatusCodeSame(422); } public function testDeleteRibNonLcrReturns204(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Rib Non LCR'); $rib = $this->seedRib($seed); $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); self::assertResponseStatusCodeSame(204); } public function testDeleteLastRibUnderLcrReturns409(): void { $client = $this->createAdminClient(); $seed = $this->seedClient('Rib LCR Solo'); $this->setPaymentType($seed, 'LCR'); $rib = $this->seedRib($seed); $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); // RG-1.13 self::assertResponseStatusCodeSame(409); } public function testRibWriteWithoutAccountingManageReturns403(): void { // Un utilisateur portant seulement commercial.clients.manage (sans // accounting.manage) ne peut ni creer, ni modifier, ni supprimer un RIB. $seed = $this->seedClient('Rib Forbidden'); $rib = $this->seedRib($seed); $credentials = $this->createUserWithPermission('commercial.clients.manage'); $client = $this->authenticatedClient($credentials['username'], $credentials['password']); $client->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN], ]); self::assertResponseStatusCodeSame(403); $client->request('PATCH', '/api/client_ribs/'.$rib->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['label' => 'Y'], ]); self::assertResponseStatusCodeSame(403); $client->request('DELETE', '/api/client_ribs/'.$rib->getId()); self::assertResponseStatusCodeSame(403); } // === Helpers === /** * Seede un ClientContact rattache a un client (sans passer par l'API). */ private function seedContact(ClientEntity $client, string $firstName): ClientContact { $em = $this->getEm(); $contact = new ClientContact(); $contact->setFirstName($firstName); $contact->setClient($client); $em->persist($contact); $em->flush(); return $contact; } /** * Seede un ClientRib valide rattache a un client (sans passer par l'API). */ private function seedRib(ClientEntity $client): ClientRib { $em = $this->getEm(); $rib = new ClientRib(); $rib->setLabel('Seed RIB'); $rib->setBic(self::VALID_BIC); $rib->setIban(self::VALID_IBAN); $rib->setClient($client); $em->persist($rib); $em->flush(); return $rib; } /** * Affecte un type de reglement (par code) au client seede. */ private function setPaymentType(ClientEntity $client, string $code): void { $em = $this->getEm(); $type = $em->getRepository(PaymentType::class)->findOneBy(['code' => $code]); self::assertNotNull($type, sprintf('PaymentType "%s" introuvable (fixtures).', $code)); $managed = $em->getRepository(ClientEntity::class)->find($client->getId()); $managed->setPaymentType($type); $em->flush(); } /** * Retourne l'IRI du premier site seede (fixtures Sites). Skip en amont si le * module Sites est desactive. */ private function firstSiteIri(): string { $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.'); return '/api/sites/'.$site->getId(); } }