= 1 site) sont DEJA couverts par * ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier * cible : * - RG-1.06 / RG-1.07 / RG-1.08 : exclusivite is_prospect vs * is_delivery / is_billing ; * - RG-1.11 : billing_email obligatoire ssi is_billing ; * - RG-1.29 (ERP-78) : les categories de code DISTRIBUTEUR / COURTIER sont * interdites sur une adresse (-> 422) ; toute autre categorie est acceptee. * * Depuis ERP-76, ces regles sont portees par des Assert\Callback sur l'entite * ClientAddress (mirror applicatif des CHECK Postgres) : la combinaison invalide * est donc rejetee en 422 AVANT la base, et non plus par une violation CHECK * remontant en 500. Les CHECK BDD restent en filet de securite (non testes ici, * inatteignables tant que les validators applicatifs passent en premier). * * @internal */ final class ClientAddressTest extends AbstractCommercialApiTestCase { private const string LD = 'application/ld+json'; /** * RG-1.06 / RG-1.07 : une adresse de prospection ne peut pas etre une * adresse de livraison -> 422 (Assert\Callback, mirror du CHECK * chk_client_address_prospect_exclusive). */ public function testProspectAddressCannotBeDelivery(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Prospect Delivery'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isProspect' => true, 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); self::assertResponseStatusCodeSame(422); } /** * RG-1.06 / RG-1.08 : une adresse de prospection ne peut pas etre une * adresse de facturation -> 422. On fournit billingEmail pour que la seule * violation possible soit l'exclusivite prospect/billing. */ public function testProspectAddressCannotBeBilling(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Prospect Billing'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isProspect' => true, 'isBilling' => true, 'billingEmail' => 'facturation@test.fr', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); self::assertResponseStatusCodeSame(422); } /** * RG-1.11 : une adresse de facturation exige un billingEmail -> 422. */ public function testBillingAddressRequiresBillingEmail(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Billing No Email'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isBilling' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); self::assertResponseStatusCodeSame(422); } /** * RG-1.11 (cas chaine vide) : une adresse de facturation avec un billingEmail * vide ("") doit etre rejetee en 422, et NON passer la validation pour finir * en 500 sur le CHECK BDD. Le ClientAddressProcessor normalise "" -> null * APRES la validation : le callback doit donc traiter "" comme « absent ». */ public function testBillingAddressRejectsEmptyBillingEmail(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Billing Empty Email'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isBilling' => true, 'billingEmail' => '', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); self::assertResponseStatusCodeSame(422); } /** * RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un * billingEmail -> 422. */ public function testNonBillingAddressRejectsBillingEmail(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Non Billing With Email'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isDelivery' => true, 'isBilling' => false, 'billingEmail' => 'parasite@test.fr', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); self::assertResponseStatusCodeSame(422); } /** * RG-1.11 (sens inverse, cas chaine vide) : une adresse NON facturable avec * un billingEmail vide ("") est ACCEPTEE (201). Le "" equivaut a « pas * d'email » : il ne doit pas declencher la violation « email interdit hors * facturation » (sinon un champ simplement vide serait refuse a tort). */ public function testNonBillingAddressAcceptsEmptyBillingEmail(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Non Billing Empty Email'); $category = $this->createCategory('SECTEUR'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isDelivery' => true, 'isBilling' => false, 'billingEmail' => '', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(201); } /** * ERP-119 : une adresse de facturation accepte un 2e email (optionnel, max 2). */ public function testBillingAddressAcceptsTwoEmails(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Billing Two Emails'); $category = $this->createCategory('SECTEUR'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isBilling' => true, 'billingEmail' => 'facturation@test.fr', 'billingEmailSecondary' => 'compta@test.fr', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(201); } /** * ERP-119 : le 2e email de facturation, comme le principal, n'est autorise que * sur une adresse de facturation -> 422 avec violation sur billingEmailSecondary. */ public function testSecondaryBillingEmailRejectedOnNonBillingAddress(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Secondary Email Non Billing'); $category = $this->createCategory('SECTEUR'); $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [ 'isDelivery' => true, 'billingEmailSecondary' => 'compta@test.fr', 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ])->toArray(false); self::assertResponseStatusCodeSame(422); $byPath = $this->violationsByPath($body); self::assertArrayHasKey('billingEmailSecondary', $byPath); } /** * RG-1.29 : poster une categorie de type DISTRIBUTEUR sur une adresse -> 422 * avec violation sur le champ `categories`. */ public function testAddressRejectsDistributorCategory(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Distributor Cat'); $category = $this->createCategory('DISTRIBUTEUR'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(422); self::assertStringContainsString( 'Type de catégorie non autorisé sur une adresse.', (string) $client->getResponse()->getContent(false), ); } /** * RG-1.29 : poster une categorie de type COURTIER sur une adresse -> 422. */ public function testAddressRejectsBrokerCategory(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Broker Cat'); $category = $this->createCategory('COURTIER'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(422); } /** * RG-1.29 : une categorie de type SECTEUR est autorisee sur une adresse. */ public function testAddressAcceptsSectorCategory(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Sector Cat'); $category = $this->createCategory('SECTEUR'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(201); } /** * RG-1.29 : une categorie de type AUTRE est autorisee sur une adresse. */ public function testAddressAcceptsOtherCategory(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Other Cat'); $category = $this->createCategory('AUTRE'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(201); } /** * Spec-front § Adresse : au moins une categorie est obligatoire sur une * adresse. POST sans categorie (mais avec site) -> 422. */ public function testAddressRequiresAtLeastOneCategory(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address No Cat'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], ], ]); self::assertResponseStatusCodeSame(422); } /** * RG (ERP-119) : au moins un type d'adresse (Prospection / Livraison / * Facturation) est obligatoire. POST sans aucun drapeau de type -> 422, avec * une violation portee sur `isProspect` (mappee sous le select « Type * d'adresse » cote front via ClientAddressBlock). */ public function testAddressRequiresAtLeastOneType(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address No Type'); $category = $this->createCategory('SECTEUR'); $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [ 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ])->toArray(false); self::assertResponseStatusCodeSame(422); $byPath = $this->violationsByPath($body); self::assertArrayHasKey('isProspect', $byPath); self::assertSame('Le type d\'adresse est obligatoire.', $byPath['isProspect']); } /** * Nouveaux types d'adresse (ERP-119) : Courtier et Distributeur sont acceptes * comme types autonomes (avec site + categorie). is_broker / is_distributor. */ public function testBrokerAddressAccepted(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Broker Type'); $category = $this->createCategory('SECTEUR'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isBroker' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(201); } public function testDistributorAddressAccepted(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Distributor Type'); $category = $this->createCategory('SECTEUR'); $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD], 'json' => [ 'isDistributor' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ]); self::assertResponseStatusCodeSame(201); } /** * Courtier / Distributeur sont des types AUTONOMES exclusifs : les combiner avec * un autre usage (ici Livraison) -> 422, violation sur isProspect (mappee sous le * select Type d'adresse). Miroir applicatif du CHECK chk_client_address_broker_exclusive. */ public function testExclusiveAddressTypeRejected(): void { $this->skipIfSitesModuleDisabled(); $client = $this->createAdminClient(); $seed = $this->seedClient('Address Broker Mix'); $category = $this->createCategory('SECTEUR'); $body = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'json' => [ 'isBroker' => true, 'isDelivery' => true, 'postalCode' => '86100', 'city' => 'Châtellerault', 'street' => '1 rue du Test', 'sites' => [$this->firstSiteIri()], 'categories' => ['/api/categories/'.$category->getId()], ], ])->toArray(false); self::assertResponseStatusCodeSame(422); $byPath = $this->violationsByPath($body); self::assertArrayHasKey('isProspect', $byPath); self::assertSame('Une adresse Courtier ne peut pas avoir d\'autre type.', $byPath['isProspect']); } /** * Retourne l'IRI du premier site seede (fixtures Sites). */ 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(); } }