From 027e80cc4b7e6543a713db5eadd5b9ebc610c6f0 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 1 Jun 2026 16:50:05 +0200 Subject: [PATCH] test(commercial) : cover RG-1.01..1.29 except role-gated (M1) --- docs/specs/M1-clients/cahier-test-back-M1.md | 79 ++++++++++ .../Commercial/Api/ClientAddressTest.php | 149 ++++++++++++++++++ .../Commercial/Api/ClientArchiveTest.php | 47 ++++++ .../Module/Commercial/Api/ClientAuditTest.php | 142 +++++++++++++++++ .../Api/ClientFormulaireMainTest.php | 84 ++++++++++ .../Commercial/Api/ClientMigrationTest.php | 67 ++++++++ .../Commercial/Api/ClientPatchStrictTest.php | 52 ++++++ .../Commercial/Api/ClientSecurityTest.php | 57 +++++++ .../Commercial/Api/ClientUniquenessTest.php | 73 +++++++++ 9 files changed, 750 insertions(+) create mode 100644 docs/specs/M1-clients/cahier-test-back-M1.md create mode 100644 tests/Module/Commercial/Api/ClientAddressTest.php create mode 100644 tests/Module/Commercial/Api/ClientArchiveTest.php create mode 100644 tests/Module/Commercial/Api/ClientAuditTest.php create mode 100644 tests/Module/Commercial/Api/ClientFormulaireMainTest.php create mode 100644 tests/Module/Commercial/Api/ClientMigrationTest.php create mode 100644 tests/Module/Commercial/Api/ClientPatchStrictTest.php create mode 100644 tests/Module/Commercial/Api/ClientSecurityTest.php create mode 100644 tests/Module/Commercial/Api/ClientUniquenessTest.php diff --git a/docs/specs/M1-clients/cahier-test-back-M1.md b/docs/specs/M1-clients/cahier-test-back-M1.md new file mode 100644 index 0000000..95afbc9 --- /dev/null +++ b/docs/specs/M1-clients/cahier-test-back-M1.md @@ -0,0 +1,79 @@ +# Cahier de test back — M1 Répertoire clients (ticket ERP-60 / #478) + +Mapping **toutes les RG (§ 7) → test(s) PHPUnit**, à jour après ERP-60. + +Légende source : `ERP-55` `ERP-56` `ERP-57` `ERP-58` = tests écrits par les wagons +précédents ; **`ERP-60`** = tests ajoutés par ce ticket (stratégie « combler les +trous, zéro duplication »). + +## Stratégie + +ERP-60 n'écrit QUE les tests des RG non déjà couvertes par la stack, et mappe ici +l'intégralité des RG (existantes + nouvelles + déléguées). Les tests dépendants +des **rôles métier** (matrice RBAC bureau/compta/commerciale/usine + RG-1.04 +fonctionnel) sont **délégués à ERP-74 (#493)** : ces rôles n'existent qu'après le +merge de la stack. + +## Mapping RG → test + +| RG | Intitulé | Test(s) | Source | +|----|----------|---------|--------| +| RG-1.01 | Prénom OU nom obligatoire → 422 | `ClientApiTest::testPostWithoutFirstOrLastNameReturns422` ; `ClientProcessorTest` (unit) | ERP-55 | +| RG-1.02 | phoneSecondary persisté ; max 2 téléphones | `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` ; `::testThirdPhoneFieldIsIgnored` | **ERP-60** | +| RG-1.03 | distributor/broker exclusifs + type catégorie | `ClientApiTest::testPostWithDistributorAndBrokerReturns422` ; `::testPostDistributorReferencingNonDistributorReturns422` ; `::testPostValidDistributorReturns201` ; `ClientProcessorTest` (unit) | ERP-55 | +| RG-1.04 | Onglet Information obligatoire pour rôle Commerciale | `ClientProcessorTest::testCommercialeIncompleteInformationIsUnprocessable` ; `::testNonCommercialeSkipsInformationCompleteness` (unit, dormant). **Test fonctionnel + durcissement → ERP-74** | ERP-55 / **ERP-74** | +| RG-1.05 | Contact : prénom OU nom → 422 (CHECK) | `ClientSubResourceApiTest::testPostContactWithoutNameReturns422` | ERP-57 | +| RG-1.06/07/08 | Adresse prospect exclusive de livraison/facturation (CHECK) | `ClientAddressTest::testProspectAddressCannotBeDelivery` ; `::testProspectAddressCannotBeBilling` | **ERP-60** | +| RG-1.09 | Code postal `^[0-9]{4,5}$` → 422 | `ClientSubResourceApiTest::testPostAddressWithInvalidPostalCodeReturns422` | ERP-57 | +| RG-1.10 | ≥ 1 site sur adresse → 422 | `ClientSubResourceApiTest::testPostAddressWithoutSiteReturns422` | ERP-57 | +| RG-1.11 | billingEmail obligatoire ssi isBilling (CHECK) | `ClientAddressTest::testBillingAddressRequiresBillingEmail` ; `::testNonBillingAddressRejectsBillingEmail` | **ERP-60** | +| RG-1.12 | Virement → banque obligatoire → 422 | `ClientProcessorTest::testVirementWithoutBankIsUnprocessable` ; `::testVirementWithBankPasses` (unit) | ERP-55 | +| RG-1.13 | LCR → ≥ 1 RIB ; DELETE dernier RIB en LCR → 409 | `ClientProcessorTest::testLcrWithoutRibIsUnprocessable` / `::testLcrWithRibPasses` (unit) ; `ClientSubResourceApiTest::testDeleteLastRibUnderLcrReturns409` / `::testDeleteRibNonLcrReturns204` | ERP-55 / ERP-57 | +| RG-1.14 | ≥ 1 bloc Contact pour finaliser l'onglet | **Front-driven (pas de state machine back).** Back voisin : `ClientSubResourceApiTest::testDeleteLastContactReturns409` | ERP-57 | +| RG-1.15 | ~~Unicité SIREN~~ supprimée (Q4) — SIREN partageable | `ClientUniquenessTest::testDuplicateSirenIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** | +| RG-1.16 | companyName unique (case-insensitive) parmi actifs → 409 | `ClientApiTest::testPostDuplicateCompanyNameReturns409` ; `ClientMigrationTest::testCompanyNameActivePartialIndexExistsExactlyOnce` | ERP-55 / **ERP-60** | +| RG-1.17 | ~~Unicité email~~ supprimée (Q4) — email partageable | `ClientUniquenessTest::testDuplicateEmailIsAllowed` ; `ClientMigrationTest::testNoSirenOrEmailUniqueIndex` | **ERP-60** | +| RG-1.18 | companyName upper-cased serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testCompanyNameIsUppercased` (unit) | ERP-55 | +| RG-1.19 | firstName/lastName capitalize serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPersonNameIsTitleCased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` | ERP-55 / ERP-57 | +| RG-1.20 | Téléphones chiffres-seuls serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testPhoneKeepsOnlyDigits` (unit) ; `ClientFormulaireMainTest::testPostPersistsSecondaryPhoneNormalized` (secondary) | ERP-55 / **ERP-60** | +| RG-1.21 | email lowercase serveur | `ClientApiTest::testPostNormalizesTextFields` ; `ClientFieldNormalizerTest::testEmailIsLowercased` (unit) ; `ClientSubResourceApiTest::testPostContactNormalizesFields` / `::testPostAddressNormalizesBillingEmail` | ERP-55 / ERP-57 | +| RG-1.22 | Archive : permission `archive` + archivedAt + aucun autre champ | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` ; `::testPatchArchiveWithOtherFieldReturns422` ; `ClientProcessorTest` (unit, gating archive) | ERP-55 | +| RG-1.23 | Restauration : archivedAt=null ; **409 si conflit d'unicité** | `ClientApiTest::testPatchArchiveSetsArchivedAtThenRestore` (cas nominal) ; **`ClientArchiveTest::testRestoreConflictReturns409`** (409 restauration, gap P1) | ERP-55 / **ERP-60** | +| RG-1.24 | Liste exclut les archivés par défaut | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 | +| RG-1.25 | `?includeArchived=true` inclut les archivés | `ClientApiTest::testListIncludeArchivedReturnsArchived` | ERP-55 | +| RG-1.26 | Tri par défaut companyName ASC | `ClientApiTest::testListSortedByCompanyNameAscAndExcludesArchived` | ERP-55 | +| RG-1.27 | Timestampable/Blamable : created* figés, updated* mis à jour | `ClientAuditTest::testCreatedFrozenAndUpdatedByReflectsModifier` | **ERP-60** | +| RG-1.28 | PATCH multi-groupes sans permission → 403 strict (tout le payload) | `ClientProcessorTest::testStrictMixWithAccountingFieldIsForbidden` / `::testAccountingFieldWithoutPermissionIsForbidden` (unit) ; **`ClientPatchStrictTest::testMixedGroupsPatchWithoutAccountingPermissionIsForbidden`** (fonctionnel) | ERP-55 / **ERP-60** | +| RG-1.29 | Catégorie d'adresse limitée aux types SECTEUR/AUTRE | **Filtrage LECTURE = front-driven** (SearchFilter `GET /api/categories?categoryType.code[]=…`). **Validation ÉCRITURE (POST/PATPH catégorie DISTRIBUTEUR/COURTIER → 422) NON IMPLÉMENTÉE côté back au M1** (absente du `ClientAddressProcessor` et de la liste § 8.1). → voir « Gaps & suivi » | — (gap) | + +## Couvertures transverses + +| Sujet | Test(s) | Source | +|-------|---------|--------| +| Audit iban/bic présents dans le diff (pas d'`#[AuditIgnore]`) | `ClientAuditTest::testRibCreateAuditIncludesIbanAndBic` | **ERP-60** | +| Sécurité générique : 401 anonyme + 403 sans `commercial.clients.view` | `ClientSecurityTest` (collection + détail) ; `ClientExportControllerTest::testForbiddenWithoutClientsViewPermission` / `::testUnauthorizedWhenAnonymous` | **ERP-60** / ERP-58 | +| Migration : index partiel unique présent (1 seul), pas de siren/email unique | `ClientMigrationTest` | **ERP-60** | +| Référentiels comptables read-only (405 écriture, 401/403) | `ReferentialApiTest` | ERP-56 | +| Export XLSX (colonnes accounting selon permission, 401/403) | `ClientExportControllerTest` | ERP-58 | + +## Délégué à ERP-74 (#493) — NE PAS faire dans ERP-60 + +- **Matrice RBAC différenciée** par rôle métier (Bureau / Compta / Commerciale / + Usine) : 200/403 par verbe et par onglet selon le rôle. +- **RG-1.04 fonctionnel** : PATCH onglet Information par une Commerciale avec + champs incomplets → 422 ; même PATCH par Admin → 200 (+ durcissement code/spec). +- Raison : ces rôles métier ne sont seedés qu'après le merge de la stack M1. + +## Gaps & suivi + +- **RG-1.29 (validation écriture)** : refuser une catégorie de type + `DISTRIBUTEUR`/`COURTIER` sur une `ClientAddress` (→ 422, violation + `categories`) n'est pas implémenté au M1. La spec § 8.1 ne le liste pas comme + cas de test back ; le filtrage de lecture est front-driven. **Suggestion** : + ouvrir un follow-up (durcissement `ClientAddressProcessor`) ou l'intégrer à + ERP-74. Aucune invention de feature dans ERP-60 (ticket test-only). +- **Violations CHECK → statut HTTP** : les CHECK d'adresse (RG-1.06/07/08/11) + sont aujourd'hui rejetées par la base (statut ≥ 400) mais sans mapping fin + vers 422 (pas d'`exception_to_status` ni de listener DBAL→HTTP). Les tests + ERP-60 assertent donc le **rejet** (≥ 400). Un mapping explicite vers 422 + serait une amélioration UX d'API (follow-up possible). diff --git a/tests/Module/Commercial/Api/ClientAddressTest.php b/tests/Module/Commercial/Api/ClientAddressTest.php new file mode 100644 index 0000000..cc1578f --- /dev/null +++ b/tests/Module/Commercial/Api/ClientAddressTest.php @@ -0,0 +1,149 @@ += 1 site) sont DEJA couverts par + * ClientSubResourceApiTest (ERP-57) et ne sont pas reduplique ici. Ce fichier + * cible les contraintes CHECK BDD non encore testees : + * - RG-1.06 / RG-1.07 / RG-1.08 : `chk_client_address_prospect_exclusive` + * (is_prospect exclusif de is_delivery / is_billing) ; + * - RG-1.11 : `chk_client_address_billing_email` (billing_email obligatoire + * ssi is_billing). + * + * Note : ces regles sont portees par des CHECK Postgres (pas d'Assert ni de + * regle Processor au M1). On verifie donc que la combinaison invalide est + * REJETEE par le serveur (statut >= 400), sans coupler le test au code exact : + * une violation CHECK non mappee remonte aujourd'hui en erreur serveur ; un + * mapping fin vers 422 serait une amelioration ulterieure (hors perimetre + * ERP-60, test-only). + * + * RG-1.29 (filtrage du type de categorie SECTEUR/AUTRE sur une adresse) n'est + * PAS testee : la validation d'ecriture correspondante n'est pas implementee + * cote back au M1 (et ne figure pas dans la liste § 8.1). Documentee comme gap + * dans le cahier de test #478. + * + * @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 (CHECK chk_client_address_prospect_exclusive). + */ + public function testProspectAddressCannotBeDelivery(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Prospect Delivery'); + + $response = $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::assertGreaterThanOrEqual(400, $response->getStatusCode()); + } + + /** + * RG-1.06 / RG-1.08 : une adresse de prospection ne peut pas etre une + * adresse de facturation (meme CHECK). 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'); + + $response = $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::assertGreaterThanOrEqual(400, $response->getStatusCode()); + } + + /** + * RG-1.11 : une adresse de facturation exige un billingEmail + * (CHECK chk_client_address_billing_email). + */ + public function testBillingAddressRequiresBillingEmail(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Billing No Email'); + + $response = $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::assertGreaterThanOrEqual(400, $response->getStatusCode()); + } + + /** + * RG-1.11 (sens inverse) : une adresse NON facturable ne peut pas porter un + * billingEmail (meme CHECK). + */ + public function testNonBillingAddressRejectsBillingEmail(): void + { + $this->skipIfSitesModuleDisabled(); + $client = $this->createAdminClient(); + $seed = $this->seedClient('Non Billing With Email'); + + $response = $client->request('POST', '/api/clients/'.$seed->getId().'/addresses', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'isBilling' => false, + 'billingEmail' => 'parasite@test.fr', + 'postalCode' => '86100', + 'city' => 'Châtellerault', + 'street' => '1 rue du Test', + 'sites' => [$this->firstSiteIri()], + ], + ]); + + self::assertGreaterThanOrEqual(400, $response->getStatusCode()); + } + + /** + * 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(); + } +} diff --git a/tests/Module/Commercial/Api/ClientArchiveTest.php b/tests/Module/Commercial/Api/ClientArchiveTest.php new file mode 100644 index 0000000..4997f71 --- /dev/null +++ b/tests/Module/Commercial/Api/ClientArchiveTest.php @@ -0,0 +1,47 @@ + collision d'unicite + * -> ClientProcessor traduit la UniqueConstraintViolationException en 409. + */ + public function testRestoreConflictReturns409(): void + { + $client = $this->createAdminClient(); + + $archived = $this->seedClient('Acme Conflict', true); + $this->seedClient('Acme Conflict', false); + + $client->request('PATCH', '/api/clients/'.$archived->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => false], + ]); + + self::assertResponseStatusCodeSame(409); + } +} diff --git a/tests/Module/Commercial/Api/ClientAuditTest.php b/tests/Module/Commercial/Api/ClientAuditTest.php new file mode 100644 index 0000000..6466c86 --- /dev/null +++ b/tests/Module/Commercial/Api/ClientAuditTest.php @@ -0,0 +1,142 @@ +get('doctrine.dbal.audit_connection'); + $this->auditConnection = $conn; + } + + protected function tearDown(): void + { + if (null !== $this->auditConnection) { + $this->auditConnection->close(); + } + parent::tearDown(); + } + + /** + * RG-1.27 : createdAt / createdBy sont poses au POST puis figes ; updatedBy + * suit l'auteur de la derniere modification. On cree en admin puis on + * modifie avec un user `commercial.clients.manage` distinct : createdBy reste + * l'admin, updatedBy devient le manager, createdAt ne bouge pas. + */ + public function testCreatedFrozenAndUpdatedByReflectsModifier(): void + { + // 1. User modificateur (non-admin) cree AVANT le reboot de kernel induit + // par les clients authentifies suivants ; il est persiste en base. + $manageCreds = $this->createUserWithPermission('commercial.clients.manage'); + + // 2. Creation en admin (createdBy = admin). + $admin = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $created = $admin->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Blamable Co', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'blamable@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ])->toArray(); + self::assertResponseStatusCodeSame(201); + $id = (int) $created['id']; + $createdAtTs = new DateTimeImmutable((string) $created['createdAt'])->getTimestamp(); + + // 3. Modification par le manager (updatedBy = manager). + $manage = $this->authenticatedClient($manageCreds['username'], $manageCreds['password']); + $manage->request('PATCH', '/api/clients/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Blamable Renamed'], + ]); + self::assertResponseStatusCodeSame(200); + + // 4. Verification cote base (etat re-charge depuis la BDD). + $em = $this->getEm(); + $em->clear(); + $reloaded = $em->getRepository(ClientEntity::class)->find($id); + self::assertNotNull($reloaded); + + self::assertSame('admin', $reloaded->getCreatedBy()?->getUserIdentifier(), 'createdBy doit rester l\'admin createur.'); + self::assertSame( + $manageCreds['username'], + $reloaded->getUpdatedBy()?->getUserIdentifier(), + 'updatedBy doit refleter le dernier modificateur.', + ); + self::assertSame($createdAtTs, $reloaded->getCreatedAt()?->getTimestamp(), 'createdAt doit etre fige au POST.'); + self::assertNotNull($reloaded->getUpdatedAt()); + self::assertGreaterThanOrEqual($createdAtTs, $reloaded->getUpdatedAt()->getTimestamp()); + } + + /** + * Audit § 6.1 : la creation d'un RIB produit une ligne audit_log + * `commercial.ClientRib` / `create` dont le snapshot contient iban et bic + * (champs volontairement NON ignores). + */ + public function testRibCreateAuditIncludesIbanAndBic(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedClient('Rib Audit Host'); + + $rib = $admin->request('POST', '/api/clients/'.$seed->getId().'/ribs', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'label' => 'Compte audite', + 'bic' => self::VALID_BIC, + 'iban' => self::VALID_IBAN, + ], + ])->toArray(); + self::assertResponseStatusCodeSame(201); + + $rows = $this->auditConnection->fetchAllAssociative( + 'SELECT changes FROM audit_log ' + .'WHERE entity_type = :type AND entity_id = :id AND action = :action ' + .'ORDER BY performed_at DESC', + ['type' => self::RIB_TYPE, 'id' => (string) $rib['id'], 'action' => 'create'], + ); + + self::assertGreaterThanOrEqual(1, count($rows), 'Un audit_log "create" doit etre genere pour le RIB.'); + + /** @var array $changes */ + $changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR); + self::assertArrayHasKey('iban', $changes, 'iban doit figurer dans le diff audite (pas d\'AuditIgnore).'); + self::assertArrayHasKey('bic', $changes, 'bic doit figurer dans le diff audite (pas d\'AuditIgnore).'); + self::assertSame(self::VALID_IBAN, $changes['iban']); + self::assertSame(self::VALID_BIC, $changes['bic']); + } +} diff --git a/tests/Module/Commercial/Api/ClientFormulaireMainTest.php b/tests/Module/Commercial/Api/ClientFormulaireMainTest.php new file mode 100644 index 0000000..e6b6eec --- /dev/null +++ b/tests/Module/Commercial/Api/ClientFormulaireMainTest.php @@ -0,0 +1,84 @@ +createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $data = $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Two Phones SARL', + 'firstName' => 'A', + 'phonePrimary' => '06.12.34.56.78', + 'phoneSecondary' => '05 49 00 11 22', + 'email' => 'twophones@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('0612345678', $data['phonePrimary']); + self::assertSame('0549001122', $data['phoneSecondary']); + } + + /** + * RG-1.02 : maximum 2 telephones — le modele n'expose que phonePrimary et + * phoneSecondary. Un eventuel 3e champ envoye par un appel API direct est + * ignore (aucune 3e colonne), il ne peut donc pas creer un troisieme numero. + */ + public function testThirdPhoneFieldIsIgnored(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $data = $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Third Phone SARL', + 'firstName' => 'A', + 'phonePrimary' => '0612345678', + 'phoneSecondary' => '0549001122', + 'phoneTertiary' => '0700000000', + 'email' => 'thirdphone@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // Le champ inconnu est ignore par le denormaliseur : il n'apparait pas + // dans la representation et n'a pas ete persiste. + self::assertArrayNotHasKey('phoneTertiary', $data); + + // Confirmation cote base : seules les 2 colonnes telephone existent. + $persisted = $this->getEm()->getRepository(ClientEntity::class)->find($data['id']); + self::assertNotNull($persisted); + self::assertSame('0612345678', $persisted->getPhonePrimary()); + self::assertSame('0549001122', $persisted->getPhoneSecondary()); + } +} diff --git a/tests/Module/Commercial/Api/ClientMigrationTest.php b/tests/Module/Commercial/Api/ClientMigrationTest.php new file mode 100644 index 0000000..2e0af76 --- /dev/null +++ b/tests/Module/Commercial/Api/ClientMigrationTest.php @@ -0,0 +1,67 @@ +clientIndexes(); + + $companyNameIndexes = array_filter( + $rows, + static fn (array $r): bool => 'uq_client_company_name_active' === $r['indexname'], + ); + + self::assertCount( + 1, + $companyNameIndexes, + 'Il doit exister exactement UN index uq_client_company_name_active.', + ); + + // Confirme la nature fonctionnelle (LOWER) + partielle (WHERE) de l'index. + // Postgres serialise l'expression sous la forme `lower((company_name)::text)`, + // d'ou des verifications de sous-chaines distinctes. + $def = strtolower((string) array_values($companyNameIndexes)[0]['indexdef']); + self::assertStringContainsString('unique', $def); + self::assertStringContainsString('lower', $def); + self::assertStringContainsString('company_name', $def); + self::assertStringContainsString('where', $def, 'L\'index doit etre partiel (clause WHERE sur les actifs).'); + } + + public function testNoSirenOrEmailUniqueIndex(): void + { + $names = array_map(static fn (array $r): string => $r['indexname'], $this->clientIndexes()); + + // RG-1.15 / RG-1.17 supprimees (Q4) : aucun index unique siren / email. + self::assertNotContains('uq_client_siren_active', $names); + self::assertNotContains('uq_client_email_active', $names); + } + + /** + * @return list + */ + private function clientIndexes(): array + { + self::bootKernel(); + + /** @var list $rows */ + return $this->getEm()->getConnection()->fetchAllAssociative( + "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'client'", + ); + } +} diff --git a/tests/Module/Commercial/Api/ClientPatchStrictTest.php b/tests/Module/Commercial/Api/ClientPatchStrictTest.php new file mode 100644 index 0000000..c8839b2 --- /dev/null +++ b/tests/Module/Commercial/Api/ClientPatchStrictTest.php @@ -0,0 +1,52 @@ +seedClient('Strict Mix'); + $credentials = $this->createUserWithPermission('commercial.clients.manage'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + $client->request('PATCH', '/api/clients/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => [ + 'companyName' => 'Renamed Strict', + 'siren' => '123456789', + ], + ]); + + // RG-1.28 : 403 strict (le champ comptable siren exige accounting.manage). + self::assertResponseStatusCodeSame(403); + + // Aucun champ applique : le companyName d'origine est intact. + $em = $this->getEm(); + $em->clear(); + $reloaded = $em->getRepository(ClientEntity::class)->find($seed->getId()); + self::assertNotNull($reloaded); + self::assertSame('STRICT MIX', $reloaded->getCompanyName()); + } +} diff --git a/tests/Module/Commercial/Api/ClientSecurityTest.php b/tests/Module/Commercial/Api/ClientSecurityTest.php new file mode 100644 index 0000000..4d9abf1 --- /dev/null +++ b/tests/Module/Commercial/Api/ClientSecurityTest.php @@ -0,0 +1,57 @@ +request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]); + + self::assertResponseStatusCodeSame(401); + } + + public function testAnonymousGetItemReturns401(): void + { + $seed = $this->seedClient('Anon Item'); + $client = self::createClient(); + + $client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]); + + self::assertResponseStatusCodeSame(401); + } + + public function testForbiddenWithoutClientsViewPermission(): void + { + // User authentifie portant une permission SANS rapport avec les clients. + $seed = $this->seedClient('Forbidden Target'); + $credentials = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($credentials['username'], $credentials['password']); + + // Collection. + $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + + // Detail. + $client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Module/Commercial/Api/ClientUniquenessTest.php b/tests/Module/Commercial/Api/ClientUniquenessTest.php new file mode 100644 index 0000000..ab4cff1 --- /dev/null +++ b/tests/Module/Commercial/Api/ClientUniquenessTest.php @@ -0,0 +1,73 @@ + 409) est DEJA couvert par + * ClientApiTest::testPostDuplicateCompanyNameReturns409 (ERP-55). Ce fichier + * verifie l'envers de la decision Q4 (29/05/2026) : le SIREN (RG-1.15 supprimee) + * et l'email (RG-1.17 supprimee) NE SONT PLUS contraints uniques. + * + * @internal + */ +final class ClientUniquenessTest extends AbstractCommercialApiTestCase +{ + private const string LD = 'application/ld+json'; + + /** + * RG-1.16 / RG-1.17 (Q4) : deux clients actifs peuvent partager le meme + * email principal — aucune contrainte d'unicite (un email peut servir + * plusieurs clients). + */ + public function testDuplicateEmailIsAllowed(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + $iri = '/api/categories/'.$cat->getId(); + + $payload = static fn (string $name): array => [ + 'companyName' => $name, + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'partage@test.fr', + 'categories' => [$iri], + ]; + + $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share One')]); + self::assertResponseStatusCodeSame(201); + + // Meme email, nom different -> doit passer (pas d'index unique email). + $client->request('POST', '/api/clients', ['headers' => ['Content-Type' => self::LD], 'json' => $payload('Email Share Two')]); + self::assertResponseStatusCodeSame(201); + } + + /** + * RG-1.15 (Q4) : deux clients peuvent partager le meme SIREN (etablissements + * multiples). Le SIREN n'est pas ecrivable au POST (groupe accounting), on + * seede donc directement via l'ORM et on prouve que le flush ne leve aucune + * violation d'unicite. + */ + public function testDuplicateSirenIsAllowed(): void + { + // Boot kernel pour disposer de l'EM (pas d'appel HTTP necessaire ici). + self::bootKernel(); + $em = $this->getEm(); + + $one = $this->seedClient('Siren Share One'); + $two = $this->seedClient('Siren Share Two'); + + $one->setSiren('123456789'); + $two->setSiren('123456789'); + $em->flush(); + + // Aucune exception : preuve qu'il n'existe pas d'index unique sur siren. + self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($one->getId())->getSiren()); + self::assertSame('123456789', $em->getRepository(ClientEntity::class)->find($two->getId())->getSiren()); + } +}