= 1 site sur * l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse), * le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`), * RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de * garde « dernier contact ») et le gating selon permission (Contacts/Adresses = * manage, RIB = accounting.manage). Jumeau de SupplierSubResourceApiTest. * * @internal */ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase { protected function setUp(): void { parent::setUp(); // seedProvider exige >= 1 site (RG-3.03) : le module Sites doit etre actif. $this->skipIfSitesModuleDisabled(); } // === Contacts (security: technique.providers.manage) === public function testPostContactNormalizesFields(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Contact Host'); $data = $client->request('POST', '/api/providers/'.$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-3.11 : 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']); } /** * RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est * rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName. * Ici seul jobTitle est fourni (hors CHECK). */ public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Contact No Name'); $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => ['jobTitle' => 'Directeur'], ]); self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false))); } public function testPostContactOnMissingProviderReturns404(): void { $client = $this->createAdminClient(); $client->request('POST', '/api/providers/999999/contacts', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => ['firstName' => 'Orphan'], ]); self::assertResponseStatusCodeSame(404); } public function testPatchContactNormalizesFields(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Contact Patch'); $contact = $this->addContact($seed, 'Marie', 'Martin'); $data = $client->request('PATCH', '/api/provider_contacts/'.$contact->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['lastName' => 'durand'], ])->toArray(); self::assertResponseStatusCodeSame(200); // Normalisation aussi sur PATCH : "durand" -> "Durand". self::assertSame('Durand', $data['lastName']); } public function testDeleteLastContactReturns204(): void { // M3 : pas de garde « dernier contact » (RG-3.12 front-driven) — la // suppression du dernier contact est libre (204). $client = $this->createAdminClient(); $seed = $this->seedProvider('Contact Solo'); $contact = $this->addContact($seed, 'Unique', 'Contact'); $client->request('DELETE', '/api/provider_contacts/'.$contact->getId()); self::assertResponseStatusCodeSame(204); } public function testContactWriteWithoutManageReturns403(): void { // Un user sans permission technique.providers.manage -> 403 sur la sous-ressource. $seed = $this->seedProvider('Contact Forbidden'); $creds = $this->createUserWithPermission('core.users.view'); $http = $this->authenticatedClient($creds['username'], $creds['password']); $http->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['firstName' => 'Nope'], ]); self::assertResponseStatusCodeSame(403); } // === Adresses (security: technique.providers.manage) === public function testPostAddressWithValidPayloadReturns201(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Address Host'); $category = $this->providerCategory('NETTOYAGE'); $data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], 'categories' => ['/api/categories/'.$category->getId()], ], ])->toArray(); self::assertResponseStatusCodeSame(201); self::assertSame('Châtellerault', $data['city']); } public function testPostAddressWithoutSiteReturns422(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Address No Site'); $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [], 'categories' => ['/api/categories/'.$this->providerCategory()->getId()], ], ]); // RG-3.05 (Assert\Count min 1 sur sites). self::assertResponseStatusCodeSame(422); } public function testPostAddressWithInvalidPostalCodeReturns422(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Address Bad CP'); $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'postalCode' => '123', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], 'categories' => ['/api/categories/'.$this->providerCategory()->getId()], ], ]); // RG-3.06 (Assert\Regex ^[0-9]{4,5}$). self::assertResponseStatusCodeSame(422); } public function testPostAddressWithNonPrestataireCategoryReturns422(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Address Bad Cat'); $foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09). $response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [ 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], 'categories' => ['/api/categories/'.$foreign->getId()], ], ]); // RG-3.09 -> 422 rattachee a categories. self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); } public function testDeleteAddressReturns204(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Address Delete'); $category = $this->providerCategory('NETTOYAGE'); $created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], 'categories' => ['/api/categories/'.$category->getId()], ], ])->toArray(); $client->request('DELETE', $created['@id']); self::assertResponseStatusCodeSame(204); } public function testAddressWriteWithoutManageReturns403(): void { $seed = $this->seedProvider('Address Forbidden'); $creds = $this->createUserWithPermission('core.users.view'); $http = $this->authenticatedClient($creds['username'], $creds['password']); $http->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()], ], ]); self::assertResponseStatusCodeSame(403); } /** * § 2.13 / RG-3.05 (cloisonnement d'ECRITURE sur l'adresse) : un user non-bypass * `sites.read_ref` (qui peut resoudre n'importe quel IRI de site, sinon 400 en * amont) ne peut attacher a l'adresse que ses propres user_site. Site hors * perimetre -> 422 sur `sites` (garde ProviderAddressProcessor). */ public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void { $seed = $this->seedProvider('Address Scope', [self::SITE_86]); $category = $this->providerCategory('NETTOYAGE'); $creds = $this->createScopedUser( ['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'], sitePostalCodes: [self::SITE_86], currentSitePostalCode: self::SITE_86, ); $client = $this->authenticatedClient($creds['username'], $creds['password']); $response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [ 'postalCode' => '17400', 'city' => 'Saint-Jean-d\'Angély', 'street' => '1 rue du Test', 'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(422); self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false))); } // === RIBs (security: technique.providers.accounting.manage) === public function testPostRibByAdminReturns201(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Rib Host'); $data = $client->request('POST', '/api/providers/'.$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->seedProvider('Rib Bad Iban'); $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [ 'headers' => ['Content-Type' => self::LD], 'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'], ]); self::assertResponseStatusCodeSame(422); } /** * Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et * un IBAN (FR) valides isolement mais de pays differents -> 422 sur `bic`. */ public function testPostRibWithBicIbanCountryMismatchReturns422OnBic(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Rib Pays Mismatch'); $response = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN], ]); self::assertResponseStatusCodeSame(422); $byPath = $this->violationsByPath($response->toArray(false)); self::assertArrayHasKey('bic', $byPath); self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']); } public function testDeleteRibNonLcrReturns204(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Rib Non LCR'); $rib = $this->addRib($seed); $client->request('DELETE', '/api/provider_ribs/'.$rib->getId()); self::assertResponseStatusCodeSame(204); } public function testDeleteLastRibUnderLcrReturns409(): void { $client = $this->createAdminClient(); $seed = $this->seedProvider('Rib LCR Solo'); $rib = $this->addRib($seed); // Passe le prestataire en LCR (seed direct). $em = $this->getEm(); $managed = $em->getRepository(Provider::class)->find($seed->getId()); $managed->setPaymentType($this->paymentType('LCR')); $em->flush(); $client->request('DELETE', '/api/provider_ribs/'.$rib->getId()); // RG-3.08 : LCR exige >= 1 RIB -> suppression du dernier refusee. self::assertResponseStatusCodeSame(409); } public function testRibWriteWithoutAccountingManageReturns403(): void { // Un user portant seulement technique.providers.manage (sans accounting.manage) // ne peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5). $seed = $this->seedProvider('Rib Forbidden'); $rib = $this->addRib($seed); $creds = $this->createUserWithPermission('technique.providers.manage'); $http = $this->authenticatedClient($creds['username'], $creds['password']); $http->request('POST', '/api/providers/'.$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/provider_ribs/'.$rib->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['label' => 'Y'], ]); self::assertResponseStatusCodeSame(403); $http->request('DELETE', '/api/provider_ribs/'.$rib->getId()); self::assertResponseStatusCodeSame(403); } }