= 1 site), RG-2.09 (enum addressType), * RG-2.10 (categorie FOURNISSEUR sur adresse), RG-2.08 (DELETE dernier RIB sous * LCR -> 409), DELETE contact libre au M2 (pas de garde « dernier contact ») et le * gating comptable des RIB (manage seul -> 403). Jumeau de ClientSubResourceApiTest. * * @internal */ final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase { // === Contacts === public function testPostContactNormalizesFields(): void { $client = $this->createAdminClient(); $seed = $this->seedSupplier('Contact Host'); $data = $client->request('POST', '/api/suppliers/'.$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-2.12 : prenom/nom Title Case, telephone chiffres seuls, email lowercase. 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 testPostContactWithoutNameReturns422OnFirstNamePath(): void { $client = $this->createAdminClient(); $seed = $this->seedSupplier('Contact No Name'); $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => ['jobTitle' => 'Directeur'], ]); // RG-2.04 (prenom OU nom obligatoire) -> 422 rattachee a firstName. self::assertResponseStatusCodeSame(422); $byPath = $this->violationsByPath($response->toArray(false)); self::assertArrayHasKey('firstName', $byPath); } public function testPostContactOnMissingSupplierReturns404(): void { $client = $this->createAdminClient(); $client->request('POST', '/api/suppliers/999999/contacts', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => ['firstName' => 'Orphan'], ]); self::assertResponseStatusCodeSame(404); } public function testDeleteLastContactReturns204(): void { // M2 : pas de garde « dernier contact » (RG-2.13 front-driven) — la // suppression du dernier contact est libre (204), contrairement au M1. $client = $this->createAdminClient(); $seed = $this->seedSupplier('Contact Solo'); $contact = $this->addContact($seed, 'Unique', 'Contact'); $client->request('DELETE', '/api/supplier_contacts/'.$contact->getId()); self::assertResponseStatusCodeSame(204); } public function testContactWriteWithoutManageReturns403(): void { // Un user sans aucune permission suppliers -> 403 sur la sous-ressource. $seed = $this->seedSupplier('Contact Forbidden'); $creds = $this->createUserWithPermission('core.users.view'); $http = $this->authenticatedClient($creds['username'], $creds['password']); $http->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['firstName' => 'Nope'], ]); self::assertResponseStatusCodeSame(403); } // === Adresses === public function testPostAddressWithValidPayloadReturns201(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedSupplier('Address Host'); $category = $this->supplierCategory('NEGOCIANT'); $data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'addressType' => 'DEPART', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ])->toArray(); self::assertResponseStatusCodeSame(201); self::assertSame('DEPART', $data['addressType']); } public function testPostAddressWithoutSiteReturns422(): void { // Sans cette garde, un module Sites desactive renverrait 404 (route // /addresses indisponible) et le test passerait pour la MAUVAISE raison // au lieu de prouver RG-2.06 (Assert\Count min 1 sur sites). $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedSupplier('Address No Site'); $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'addressType' => 'DEPART', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [], ], ]); // RG-2.06 (Assert\Count min 1 sur sites). self::assertResponseStatusCodeSame(422); } public function testPostAddressWithInvalidPostalCodeReturns422(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedSupplier('Address Bad CP'); $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'addressType' => 'DEPART', 'postalCode' => '123', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); // RG-2.05 (Assert\Regex ^[0-9]{4,5}$). self::assertResponseStatusCodeSame(422); } public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedSupplier('Address Incoherent'); // RG-2.05 : pas de controle strict de coherence CP/ville cote serveur. $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'addressType' => 'DEPART', 'postalCode' => '86100', 'city' => 'Marseille', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); self::assertResponseStatusCodeSame(201); } public function testPostAddressWithInvalidTypeReturns422(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedSupplier('Address Bad Type'); $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'addressType' => 'INVALID', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); // RG-2.09 (Assert\Choice PROSPECT|DEPART|RENDU). self::assertResponseStatusCodeSame(422); } /** * RG-2.09 : les 3 valeurs valides de addressType sont acceptees. */ public function testPostAddressWithEachValidTypeReturns201(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedSupplier('Address Types'); $siteIri = $this->firstSiteIri(); foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) { $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'addressType' => $type, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$siteIri], ], ]); self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type)); } } public function testPostAddressWithNonFournisseurCategoryReturns422(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedSupplier('Address Bad Cat'); // categorie de type CLIENT -> interdite sur une adresse fournisseur. $clientTypedCategory = $this->createCategory('SECTEUR'); $response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [ 'addressType' => 'DEPART', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$clientTypedCategory->getId()], ], ]); // RG-2.10 -> 422 rattachee a categories. self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); } // === RIBs === public function testPostRibByAdminReturns201(): void { $client = $this->createAdminClient(); $seed = $this->seedSupplier('Rib Host'); $data = $client->request('POST', '/api/suppliers/'.$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->seedSupplier('Rib Bad Iban'); $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'], ]); self::assertResponseStatusCodeSame(422); } public function testDeleteRibNonLcrReturns204(): void { $client = $this->createAdminClient(); $seed = $this->seedSupplier('Rib Non LCR'); $rib = $this->addRib($seed); $client->request('DELETE', '/api/supplier_ribs/'.$rib->getId()); self::assertResponseStatusCodeSame(204); } public function testDeleteLastRibUnderLcrReturns409(): void { $client = $this->createAdminClient(); $seed = $this->seedSupplier('Rib LCR Solo'); $rib = $this->addRib($seed); // Passe le fournisseur en LCR (seed direct). $em = $this->getEm(); $managed = $em->getRepository(Supplier::class)->find($seed->getId()); $managed->setPaymentType($this->paymentType('LCR')); $em->flush(); $client->request('DELETE', '/api/supplier_ribs/'.$rib->getId()); // RG-2.08 : LCR exige >= 1 RIB -> suppression du dernier refusee. self::assertResponseStatusCodeSame(409); } public function testRibWriteWithoutAccountingManageReturns403(): void { // Un user portant seulement suppliers.manage (sans accounting.manage) ne // peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5). $seed = $this->seedSupplier('Rib Forbidden'); $rib = $this->addRib($seed); $creds = $this->createUserWithPermission('commercial.suppliers.manage'); $http = $this->authenticatedClient($creds['username'], $creds['password']); $http->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN], ]); self::assertResponseStatusCodeSame(403); $http->request('PATCH', '/api/supplier_ribs/'.$rib->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['label' => 'Y'], ]); self::assertResponseStatusCodeSame(403); $http->request('DELETE', '/api/supplier_ribs/'.$rib->getId()); self::assertResponseStatusCodeSame(403); } // === Helpers === // violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase. 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(); } }