From 42cc9be4ae2d3f9e6a1557763275032174a47106 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Sun, 7 Jun 2026 10:45:07 +0200 Subject: [PATCH] =?UTF-8?q?test(commercial)=20:=20tests=20PHPUnit=20M2=20f?= =?UTF-8?q?ournisseurs=20(matrice=20RG=20+=20contrat=20s=C3=A9rialisation?= =?UTF-8?q?=20+=20DoD=20JSON=20r=C3=A9el)=20(ERP-92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite fonctionnelle M2 assertant sur le CORPS JSON (jamais les annotations), jumelle de la suite clients M1 : - contrat de sérialisation : 4 régressions M1 re-testées (RIB gaté absent pour Commerciale, booléens triageProvider/isArchived présents, embed categories[].code/name, embed sites[].name/postalCode objet) + enveloppe AP4 (member/totalItems/view, archivés exclus) + suppression du contact inline ; - matrice RBAC réelle (app:seed-rbac) bureau/compta/commerciale/usine 200/403, gating accounting par omission de clé, mode strict PATCH (RG-2.16) ; - RG-2.03/2.04/2.05/2.06/2.07/2.08/2.09/2.10/2.11/2.12/2.14/2.15/2.17 ; - sous-ressources contacts/adresses/ribs (CRUD, sécurité, normalisation) ; - anti N+1 liste (compte de requêtes constant), audit Supplier + RIB iban/bic. Fix de contrat découvert et corrigé (sinon DoD figée sur un contrat faux) : les référentiels comptables (TvaMode/PaymentType/PaymentDelay/Bank) ne portaient que le groupe client:read:accounting (M1) → sur un fournisseur ils sortaient en IRI nu. Ajout de supplier:read:accounting → objet {id, code, label} embarqué. makefile : test-db-setup recrée l'index partiel uq_supplier_company_name_active (droppé par schema:update comme pour le client) — oubli M2. DoD § 4.0.bis : réponses JSON RÉELLES (liste + détail admin/commerciale) collées, capturées via SupplierSerializationContractTest. --- docs/specs/M2-suppliers/spec-back.md | 113 +++--- makefile | 1 + src/Module/Commercial/Domain/Entity/Bank.php | 9 +- .../Commercial/Domain/Entity/PaymentDelay.php | 9 +- .../Commercial/Domain/Entity/PaymentType.php | 9 +- .../Commercial/Domain/Entity/TvaMode.php | 9 +- .../Api/AbstractSupplierApiTestCase.php | 319 +++++++++++++++ .../Api/SupplierAccountingApiTest.php | 94 +++++ .../Module/Commercial/Api/SupplierApiTest.php | 177 +++++++++ .../Commercial/Api/SupplierArchiveTest.php | 36 ++ .../Commercial/Api/SupplierAuditTest.php | 140 +++++++ .../Commercial/Api/SupplierListTest.php | 118 ++++++ .../Commercial/Api/SupplierMigrationTest.php | 68 ++++ .../Api/SupplierPatchStrictTest.php | 45 +++ .../Commercial/Api/SupplierRBACMatrixTest.php | 288 ++++++++++++++ .../Api/SupplierSerializationContractTest.php | 371 ++++++++++++++++++ .../Api/SupplierSubResourceApiTest.php | 370 +++++++++++++++++ .../Commercial/Api/SupplierUniquenessTest.php | 36 ++ 18 files changed, 2148 insertions(+), 64 deletions(-) create mode 100644 tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php create mode 100644 tests/Module/Commercial/Api/SupplierAccountingApiTest.php create mode 100644 tests/Module/Commercial/Api/SupplierApiTest.php create mode 100644 tests/Module/Commercial/Api/SupplierArchiveTest.php create mode 100644 tests/Module/Commercial/Api/SupplierAuditTest.php create mode 100644 tests/Module/Commercial/Api/SupplierListTest.php create mode 100644 tests/Module/Commercial/Api/SupplierMigrationTest.php create mode 100644 tests/Module/Commercial/Api/SupplierPatchStrictTest.php create mode 100644 tests/Module/Commercial/Api/SupplierRBACMatrixTest.php create mode 100644 tests/Module/Commercial/Api/SupplierSerializationContractTest.php create mode 100644 tests/Module/Commercial/Api/SupplierSubResourceApiTest.php create mode 100644 tests/Module/Commercial/Api/SupplierUniquenessTest.php diff --git a/docs/specs/M2-suppliers/spec-back.md b/docs/specs/M2-suppliers/spec-back.md index 2194273..6906ef3 100644 --- a/docs/specs/M2-suppliers/spec-back.md +++ b/docs/specs/M2-suppliers/spec-back.md @@ -711,91 +711,108 @@ Même pattern que les jumelles `Client*` (`#[Auditable]`, `TimestampableBlamable | Scalaires Comptabilité (siren, refs…) | `supplier:read:accounting` | ✅ (gated) | refs (`tvaMode`…) id+label ∈ `supplier:read:accounting` | | `ribs[]` (label/bic/iban) | `ribs` ∈ `supplier:read:accounting` | ✅ (gated) | — | -### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle) +### 4.0.bis Réponses JSON de référence (DoD — RÉELLES, capturées ERP-92) -> **Definition of Done de cette spec back (RETEX M1 §3)** : avant d'écrire les tickets front, créer un fournisseur de test et **coller ici les réponses RÉELLES** de `GET /api/suppliers` et `GET /api/suppliers/{id}`. Les containers n'étant pas lancés au moment de la rédaction, le JSON ci-dessous est le **contrat CIBLE** — à valider/remplacer par la réponse réelle (`make start` puis `curl`). Toute donnée affichée par le front DOIT apparaître dans ce JSON. +> **Definition of Done CLÔTURÉE (ERP-92, 2026-06-05)** : les réponses ci-dessous sont **réelles**, capturées sur l'API de test via PHPUnit (`SupplierSerializationContractTest`, fournisseur complet seedé). Les `id`/timestamps sont illustratifs (run de test). Toute donnée affichée par le front DOIT apparaître dans ce JSON. Front #93→#96 peuvent démarrer. +> +> **2 constats validés à la capture** (cf. § 4.0.ter) : +> 1. 🔧 **Fix ERP-92** : les réfs comptables (`tvaMode`/`paymentDelay`/`paymentType`/`bank`) sortaient en **IRI nu** (les entités partagées ne portaient que `client:read:accounting`, pas `supplier:read:accounting`). Corrigé → objet `{id, code, label}` embarqué (le front consultation/édition affiche le libellé sans fetch). +> 2. ℹ️ **Liste « riche »** : le groupe `supplier:read` étant partagé liste+détail, la **collection embarque tout le bloc Information** (et, pour un user `accounting.view`, les scalaires compta + `ribs[]`). Comportement identique au M1 (groupe `client:read` partagé) — la datatable n'affiche que Nom/Catégories/Site(s)/MAJ, mais le payload est complet. Le gating `accounting` reste effectif (Commerciale ne voit ni compta ni `ribs` en liste comme en détail). > **Forme d'enveloppe confirmée sur le M1 réel** (API Platform 4.2) : JSON-LD **sans préfixe `hydra:`** → clés `member` / `totalItems` / `view`, avec `@type: "Collection"` et `view.@type: "PartialCollectionView"`. `Content-Type: application/ld+json; charset=utf-8`. Pagination défaut 10 confirmée. Login réel = `POST /api/login_check` (nginx réécrit vers `/login_check`), réponse `204` + cookie HttpOnly `BEARER`. -`GET /api/suppliers` (liste, ADMIN) : +`GET /api/suppliers?search=…` (liste, ADMIN — un membre) : ```json { "@context": "/api/contexts/Supplier", "@id": "/api/suppliers", "@type": "Collection", - "totalItems": 13, + "totalItems": 1, "member": [ { - "@id": "/api/suppliers/1", + "@id": "/api/suppliers/85", "@type": "Supplier", - "id": 1, - "companyName": "RECYCLA SAS", + "id": 85, + "companyName": "DOD59393F 862875", "categories": [ - {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"} + {"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT", + "categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}, + "createdAt": "…", "updatedAt": "…"} ], + "description": "Fournisseur de test complet.", + "competitors": "Concurrent A, Concurrent B", + "foundedAt": "2008-04-01T00:00:00+02:00", + "employeesCount": 42, + "revenueAmount": "1500000.00", + "directorName": "Jean Dupont", + "profitAmount": "120000.00", + "volumeForecast": 8000, + "siren": "123456789", + "accountNumber": "F0001", + "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00123456789", + "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, + "ribs": [ + {"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606", "createdAt": "…", "updatedAt": "…"} + ], + "createdAt": "…", "updatedAt": "…", "sites": [ - {"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}, - {"@id": "/api/sites/2", "id": 2, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#…"} + {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "street": "14 All. d'Argenson", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2", "fullAddress": "14 All. d'Argenson\n86100 Châtellerault"}, + {"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "street": "Z i", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00", "fullAddress": "Z i\n17400 Fontenet"} ], - "updatedAt": "2026-02-17T09:30:00+00:00", "isArchived": false } ], - "view": { - "@id": "/api/suppliers?page=1", - "@type": "PartialCollectionView", - "first": "/api/suppliers?page=1", - "last": "/api/suppliers?page=2", - "next": "/api/suppliers?page=2" - } + "view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"} } ``` -> Les fournisseurs archivés sont **exclus** du `totalItems` (sur le M1, 14 clients en base → `totalItems: 13` car 1 archivé filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository obligatoires (anti N+1). +> Les fournisseurs archivés sont **exclus** du `totalItems` (RG-2.17 — filtré par le Provider). `categories[]` (avec `code`/`name`) et `sites[]` (avec `name`/`postalCode` — **pas de `code`**) sont **embarqués** (cohérence M1/ERP-62, § 2.12) ; `sites` est l'agrégat dédoublonné des adresses via `Supplier::getSites()`. Fetch-joins repository (anti N+1) **vérifiés par test** (`SupplierListTest::testListQueryCountDoesNotGrowWithRowCount` : nombre de requêtes constant entre 2 et 4 fournisseurs). ⚠️ Le membre embarque aussi l'**Information complète** et — pour un user `accounting.view` (ici admin) — les **scalaires compta + `ribs[]`** (groupe `supplier:read` partagé liste/détail). Pour la **Commerciale** (sans `accounting.view`), `siren`/`tvaMode`/`paymentType`/`ribs`… **disparaissent** de chaque membre. -`GET /api/suppliers/1` (détail — user avec `accounting.view`) : +`GET /api/suppliers/85` (détail — user avec `accounting.view`) : ```json { - "@id": "/api/suppliers/1", + "@context": "/api/contexts/Supplier", + "@id": "/api/suppliers/85", "@type": "Supplier", - "id": 1, - "companyName": "RECYCLA SAS", + "id": 85, + "companyName": "DOD59393F 862875", "categories": [ - {"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"} + {"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT", + "categoryType": {"@id": "/api/category_types/602", "@type": "CategoryType", "id": 602, "code": "FOURNISSEUR", "label": "Fournisseur"}} ], - "description": "…", "competitors": "…", "foundedAt": "2008-04-01", - "employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…", - "profitAmount": "120000.00", "volumeForecast": 8000, + "description": "Fournisseur de test complet.", "competitors": "Concurrent A, Concurrent B", + "foundedAt": "2008-04-01T00:00:00+02:00", "employeesCount": 42, "revenueAmount": "1500000.00", + "directorName": "Jean Dupont", "profitAmount": "120000.00", "volumeForecast": 8000, + "siren": "123456789", "accountNumber": "F0001", + "tvaMode": {"@id": "/api/tva_modes/30", "@type": "TvaMode", "id": 30, "code": "FRANCE_VENTES", "label": "France (ventes)"}, + "nTva": "FR00123456789", + "paymentDelay": {"@id": "/api/payment_delays/11", "@type": "PaymentDelay", "id": 11, "code": "J30", "label": "30 jours"}, + "paymentType": {"@id": "/api/payment_types/14", "@type": "PaymentType", "id": 14, "code": "LCR", "label": "LCR"}, "contacts": [ - {"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin", - "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null, - "email": "marie.martin@recycla.fr"} + {"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin", + "jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"} ], "addresses": [ - {"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART", - "country": "France", "postalCode": "86000", "city": "Poitiers", - "street": "12 rue des Acacias", "streetComplement": null, + {"@id": "/api/supplier_addresses/33", "@type": "SupplierAddress", "id": 33, "addressType": "DEPART", + "country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias", "bennes": 3, "triageProvider": true, - "sites": [{"@id": "/api/sites/1", "id": 1, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}], - "categories": [{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}], - "contacts": [{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin"}]} + "sites": [ + {"@type": "Site", "@id": "/api/sites/87", "id": 87, "name": "Chatellerault", "postalCode": "86100", "city": "Châtellerault", "color": "#056CF2"}, + {"@type": "Site", "@id": "/api/sites/88", "id": 88, "name": "Saint-Jean", "postalCode": "17400", "city": "Fontenet", "color": "#F3CB00"} + ], + "contacts": [{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin"}], + "categories": [{"@type": "Category", "@id": "/api/categories/2279", "id": 2279, "name": "test_cli_cat_fr_negociant", "code": "NEGOCIANT"}]} ], - "siren": "123456789", "accountNumber": "F0001", - "tvaMode": {"@id": "/api/tva_modes/1", "id": 1, "label": "France (ventes)"}, - "nTva": "FR00123456789", - "paymentDelay": {"@id": "/api/payment_delays/2", "id": 2, "label": "30 jours"}, - "paymentType": {"@id": "/api/payment_types/2", "id": 2, "code": "LCR", "label": "LCR"}, - "bank": null, "ribs": [ - {"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal", - "bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"} + {"@id": "/api/supplier_ribs/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"} ], - "isArchived": false, "archivedAt": null, - "updatedAt": "2026-02-17T09:30:00+00:00" + "isArchived": false } ``` -> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées car le Provider retire le groupe). Le gating par **omission de clé** est confirmé confortable côté front. Le blame `updatedBy` est sérialisé en **IRI** (`"/api/me"` quand c'est l'user courant) — en tenir compte côté front. +> Pour un user **sans** `accounting.view` (ex. Commerciale) : les clés `siren`, `accountNumber`, `tvaMode`, `nTva`, `paymentDelay`, `paymentType`, `bank`, `ribs` **sont absentes** (pas `null` — réellement non sérialisées : le `SupplierReadGroupContextBuilder` n'ajoute pas le groupe). Gating par **omission de clé** confirmé sur le JSON réel (`SupplierSerializationContractTest::testRibsAbsentForCommercialeWithoutAccountingView` + `testAccountingScalarsGatedByOmission`). `bennes`/`triageProvider`/`addressType`/`addresses[].contacts` restent visibles (onglet Adresse non gaté). NB : ici `bank` est absent (paymentType=LCR sans banque) ; avec un VIREMENT, `bank` est embarqué `{id, code, label}` (fix ERP-92). ### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE) @@ -1046,7 +1063,7 @@ Le M1 a subi un aller-retour (ERP-68) faute de fixtures alignées. Pour le M2, p - [x] 3 maillons de sérialisation documentés pour chaque champ liste + détail (§ 4.0) - [x] Décision embed vs GetCollection explicite et câblée (embed détail + sous-ressources write — § 3.3 / § 3.4 / § 4.5), **pas de POST-only** -- [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)* +- [x] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — capturées via PHPUnit (ERP-92, 2026-06-05) ; fix réfs compta IRI→{id,label} inclus - [x] Matrice RBAC rôle × onglet + mode strict PATCH (§ 2.9 / RG-2.16) - [x] Pagination (n°13), COMMENT ON COLUMN (n°12), Timestampable/Blamable, Audit, routes à plat : rappelés - [x] Réutilisations M1 identifiées (référentiels compta partagés, taxonomie code/type, `usePaginatedList`, blocs, archive, normalisation) diff --git a/makefile b/makefile index 9977884..0367e92 100644 --- a/makefile +++ b/makefile @@ -229,6 +229,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_code ON category (code) WHERE deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_client_company_name_active ON client (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_supplier_company_name_active ON supplier (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/src/Module/Commercial/Domain/Entity/Bank.php b/src/Module/Commercial/Domain/Entity/Bank.php index c1c637d..1ee4542 100644 --- a/src/Module/Commercial/Domain/Entity/Bank.php +++ b/src/Module/Commercial/Domain/Entity/Bank.php @@ -20,7 +20,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe - * `client:read:accounting` permet l'embarquement dans la reponse Client. + * `client:read:accounting` permet l'embarquement dans la reponse Client ; + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). */ #[ApiResource( operations: [ @@ -47,15 +48,15 @@ class Bank #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['bank:read', 'client:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['bank:read', 'client:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['bank:read', 'client:read:accounting'])] + #[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentDelay.php b/src/Module/Commercial/Domain/Entity/PaymentDelay.php index 739ccc3..5e8be75 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentDelay.php +++ b/src/Module/Commercial/Domain/Entity/PaymentDelay.php @@ -20,7 +20,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe - * `client:read:accounting` permet l'embarquement dans la reponse Client. + * `client:read:accounting` permet l'embarquement dans la reponse Client ; + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). */ #[ApiResource( operations: [ @@ -47,15 +48,15 @@ class PaymentDelay #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['payment_delay:read', 'client:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['payment_delay:read', 'client:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['payment_delay:read', 'client:read:accounting'])] + #[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/PaymentType.php b/src/Module/Commercial/Domain/Entity/PaymentType.php index 6b87bc8..af045c9 100644 --- a/src/Module/Commercial/Domain/Entity/PaymentType.php +++ b/src/Module/Commercial/Domain/Entity/PaymentType.php @@ -23,7 +23,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * permission commercial.clients.view ; POST/PATCH/DELETE -> 405. Pas de * Timestampable/Blamable (referentiel statique whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED). Le groupe - * `client:read:accounting` permet l'embarquement dans la reponse Client. + * `client:read:accounting` permet l'embarquement dans la reponse Client ; + * `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0). */ #[ApiResource( operations: [ @@ -50,15 +51,15 @@ class PaymentType #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['payment_type:read', 'client:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['payment_type:read', 'client:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['payment_type:read', 'client:read:accounting'])] + #[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/src/Module/Commercial/Domain/Entity/TvaMode.php b/src/Module/Commercial/Domain/Entity/TvaMode.php index f971e47..e28072f 100644 --- a/src/Module/Commercial/Domain/Entity/TvaMode.php +++ b/src/Module/Commercial/Domain/Entity/TvaMode.php @@ -24,7 +24,8 @@ use Symfony\Component\Serializer\Attribute\Groups; * Referentiel statique : pas de Timestampable/Blamable (whiteliste dans * EntitiesAreTimestampableBlamableTest::EXCLUDED, comme CategoryType). Le * groupe `client:read:accounting` permet d'embarquer le mode dans la reponse - * d'un Client (onglet Comptabilite) au lieu d'un IRI. + * d'un Client (onglet Comptabilite) au lieu d'un IRI ; `supplier:read:accounting` + * fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0). */ #[ApiResource( operations: [ @@ -54,15 +55,15 @@ class TvaMode #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['tva_mode:read', 'client:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?int $id = null; #[ORM\Column(length: 30)] - #[Groups(['tva_mode:read', 'client:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?string $code = null; #[ORM\Column(length: 120)] - #[Groups(['tva_mode:read', 'client:read:accounting'])] + #[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])] private ?string $label = null; #[ORM\Column(options: ['default' => 0])] diff --git a/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php new file mode 100644 index 0000000..3d5691a --- /dev/null +++ b/tests/Module/Commercial/Api/AbstractSupplierApiTestCase.php @@ -0,0 +1,319 @@ +getEm()->createQuery('DELETE FROM '.Supplier::class)->execute(); + parent::tearDown(); + } + + /** + * Fetch-or-create une categorie de type FOURNISSEUR par code (defaut + * Negociant). Type FOURNISSEUR exige par RG-2.10 : un POST fournisseur portant + * cette categorie passe la validation. Idempotent (lookup par code, aligne sur + * l'index unique partiel uq_category_code) et auto-suffisant : ne depend pas du + * seed CategoryFixtures (que d'autres tests de la suite peuvent purger). Une + * categorie creee ici porte le prefixe de nom de test -> purgee par le parent. + */ + protected function supplierCategory(string $code = 'NEGOCIANT'): Category + { + $em = $this->getEm(); + $existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]); + if (null !== $existing) { + return $existing; + } + + $category = new Category(); + $category->setName(self::TEST_CATEGORY_PREFIX.'fr_'.strtolower($code)); + $category->setCode($code); + $category->setCategoryType($this->supplierCategoryType()); + $em->persist($category); + $em->flush(); + + return $category; + } + + /** + * Recupere (ou cree) le type FOURNISSEUR. Idempotent : la contrainte d'unicite + * sur category_type.code interdit les doublons. + */ + protected function supplierCategoryType(): CategoryType + { + $em = $this->getEm(); + $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'FOURNISSEUR']); + if (null !== $existing) { + return $existing; + } + + $type = new CategoryType(); + $type->setCode('FOURNISSEUR'); + $type->setLabel('Fournisseur'); + $em->persist($type); + $em->flush(); + + return $type; + } + + /** + * Seede directement un Supplier minimal (sans passer par l'API), pour les + * tests de liste / archivage / serialisation. Nom stocke en MAJUSCULES pour + * refleter l'etat normalise (RG-2.12) qu'aurait produit le SupplierProcessor. + * Porte une categorie FOURNISSEUR (defaut Negociant). + */ + protected function seedSupplier(string $companyName, bool $isArchived = false, string $categoryCode = 'NEGOCIANT'): Supplier + { + $em = $this->getEm(); + $supplier = new Supplier(); + $supplier->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); + $supplier->addCategory($this->supplierCategory($categoryCode)); + $supplier->setIsArchived($isArchived); + if ($isArchived) { + $supplier->setArchivedAt(new DateTimeImmutable()); + } + $em->persist($supplier); + $em->flush(); + + return $supplier; + } + + /** + * Seede un fournisseur COMPLET (sans passer par l'API — validations + * applicatives non rejouees mais CHECK BDD respectes) : onglet Information + * rempli, bloc comptable non nul (SIREN + refs), >= 1 RIB, >= 1 adresse + * multi-sites (>= 2 sites, triageProvider=true) avec >= 1 categorie + * FOURNISSEUR, >= 1 contact, >= 1 categorie sur le fournisseur. Sert de socle + * au contrat de serialisation et a la DoD (§ 4.0.bis). + * + * @param string $paymentTypeCode code du type de reglement a poser (defaut LCR, + * coherent avec le RIB seede ; RG-2.08) + */ + protected function seedCompleteSupplier(string $companyName, string $paymentTypeCode = 'LCR'): Supplier + { + $em = $this->getEm(); + + // Nom unique parmi les actifs (index partiel uq_supplier_company_name_active). + $suffix = substr(bin2hex(random_bytes(3)), 0, 6); + + $supplier = new Supplier(); + $supplier->setCompanyName(mb_strtoupper($companyName.' '.$suffix, 'UTF-8')); + $supplier->addCategory($this->supplierCategory('NEGOCIANT')); + + // Onglet Information complet (RG-2.03 : exige pour la Commerciale). + $supplier->setDescription('Fournisseur de test complet.'); + $supplier->setCompetitors('Concurrent A, Concurrent B'); + $supplier->setFoundedAt(new DateTimeImmutable('2008-04-01')); + $supplier->setEmployeesCount(42); + $supplier->setRevenueAmount('1500000.00'); + $supplier->setDirectorName('Jean Dupont'); + $supplier->setProfitAmount('120000.00'); + $supplier->setVolumeForecast(8000); + + // Bloc comptable non nul (gating par omission cote Commerciale). + $supplier->setSiren('123456789'); + $supplier->setAccountNumber('F0001'); + $supplier->setNTva('FR00123456789'); + $supplier->setTvaMode($this->tvaMode('FRANCE_VENTES')); + $supplier->setPaymentDelay($this->paymentDelay('J30')); + $supplier->setPaymentType($this->paymentType($paymentTypeCode)); + if ('VIREMENT' === $paymentTypeCode) { + $supplier->setBank($this->bank('SG')); + } + $em->persist($supplier); + + // >= 2 sites fixtures pour une adresse multi-sites (RG-2.06). + $sites = $em->getRepository(Site::class)->findBy([], null, 2); + self::assertGreaterThanOrEqual(2, count($sites), 'Au moins 2 sites fixtures requis (SitesFixtures).'); + + $contact = new SupplierContact(); + $contact->setSupplier($supplier); + $contact->setFirstName('Marie'); + $contact->setLastName('Martin'); + $contact->setJobTitle('Responsable achats'); + $contact->setPhonePrimary('0612345678'); + $contact->setEmail('marie.martin@seed.test'); + $supplier->addContact($contact); + $em->persist($contact); + + $address = new SupplierAddress(); + $address->setSupplier($supplier); + $address->setAddressType('DEPART'); + $address->setPostalCode('86000'); + $address->setCity('Poitiers'); + $address->setStreet('12 rue des Acacias'); + $address->setBennes(3); + // triageProvider=true : prouve qu'un booleen `true` est bien serialise + // (piege n°3 du M1 — la cle etait droppee). + $address->setTriageProvider(true); + foreach ($sites as $site) { + $address->addSite($site); + } + $address->addCategory($this->supplierCategory('NEGOCIANT')); + $address->addContact($contact); + $supplier->addAddress($address); + $em->persist($address); + + $rib = new SupplierRib(); + $rib->setSupplier($supplier); + $rib->setLabel('Compte principal'); + $rib->setBic(self::VALID_BIC); + $rib->setIban(self::VALID_IBAN); + $supplier->addRib($rib); + $em->persist($rib); + + $em->flush(); + + return $supplier; + } + + /** + * Ajoute un contact a un fournisseur deja persiste (seed direct). + */ + protected function addContact( + Supplier $supplier, + ?string $firstName = 'Marie', + ?string $lastName = 'Martin', + ?string $phonePrimary = null, + ?string $email = null, + int $position = 0, + ): SupplierContact { + $contact = new SupplierContact(); + $contact->setSupplier($supplier); + $contact->setFirstName($firstName); + $contact->setLastName($lastName); + $contact->setPhonePrimary($phonePrimary); + $contact->setEmail($email); + $contact->setPosition($position); + $supplier->addContact($contact); + $this->getEm()->persist($contact); + $this->getEm()->flush(); + + return $contact; + } + + /** + * Ajoute un RIB a un fournisseur deja persiste (seed direct). + */ + protected function addRib(Supplier $supplier, string $label = 'Compte principal'): SupplierRib + { + $rib = new SupplierRib(); + $rib->setSupplier($supplier); + $rib->setLabel($label); + $rib->setBic(self::VALID_BIC); + $rib->setIban(self::VALID_IBAN); + $supplier->addRib($rib); + $this->getEm()->persist($rib); + $this->getEm()->flush(); + + return $rib; + } + + /** + * Payload minimal valide de l'onglet principal (companyName + 1 categorie + * FOURNISSEUR). Si $categoryId est null, la categorie Negociant seedee est + * utilisee. + * + * @return array + */ + protected function validMainPayload(string $companyName, ?int $categoryId = null): array + { + $categoryId ??= $this->supplierCategory('NEGOCIANT')->getId(); + + return [ + 'companyName' => $companyName, + 'categories' => ['/api/categories/'.$categoryId], + ]; + } + + protected function paymentType(string $code): PaymentType + { + return $this->referential(PaymentType::class, $code); + } + + protected function paymentDelay(string $code): PaymentDelay + { + return $this->referential(PaymentDelay::class, $code); + } + + protected function tvaMode(string $code): TvaMode + { + return $this->referential(TvaMode::class, $code); + } + + protected function bank(string $code): Bank + { + return $this->referential(Bank::class, $code); + } + + /** + * Recupere un referentiel comptable seede (CommercialReferentialFixtures) par + * code. Echoue explicitement si absent (fixtures non chargees). + * + * @template T of object + * + * @param class-string $entityClass + * + * @return T + */ + private function referential(string $entityClass, string $code): object + { + $entity = $this->getEm()->getRepository($entityClass)->findOneBy(['code' => $code]); + + self::assertNotNull( + $entity, + sprintf('Referentiel %s "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $entityClass, $code), + ); + + return $entity; + } +} diff --git a/tests/Module/Commercial/Api/SupplierAccountingApiTest.php b/tests/Module/Commercial/Api/SupplierAccountingApiTest.php new file mode 100644 index 0000000..61bddcb --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierAccountingApiTest.php @@ -0,0 +1,94 @@ +createAdminClient(); + $seed = $this->seedSupplier('Virement No Bank'); + + $response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId()], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('bank', $this->violationsByPath($response->toArray(false))); + } + + public function testVirementWithBankReturns200(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Virement With Bank'); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => [ + 'paymentType' => '/api/payment_types/'.$this->paymentType('VIREMENT')->getId(), + 'bank' => '/api/banks/'.$this->bank('SG')->getId(), + ], + ]); + + self::assertResponseStatusCodeSame(200); + } + + // === RG-2.08 : LCR impose au moins un RIB === + + public function testLcrWithoutRibReturns422OnRibsPath(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Lcr No Rib'); + + $response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE, 'Accept' => self::LD], + 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertArrayHasKey('ribs', $this->violationsByPath($response->toArray(false))); + } + + public function testLcrWithRibReturns200(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Lcr With Rib'); + $this->addRib($seed); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['paymentType' => '/api/payment_types/'.$this->paymentType('LCR')->getId()], + ]); + + self::assertResponseStatusCodeSame(200); + } + + /** + * @param array $body + * + * @return array + */ + private function violationsByPath(array $body): array + { + $byPath = []; + foreach ($body['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + + return $byPath; + } +} diff --git a/tests/Module/Commercial/Api/SupplierApiTest.php b/tests/Module/Commercial/Api/SupplierApiTest.php new file mode 100644 index 0000000..5c3589e --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierApiTest.php @@ -0,0 +1,177 @@ +createAdminClient(); + $cat = $this->supplierCategory('NEGOCIANT'); + + $data = $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'recycla sas', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + // RG-2.12 : companyName normalise en MAJUSCULES sur la valeur RENVOYEE. + self::assertSame('RECYCLA SAS', $data['companyName']); + // Embed categorie : code/name presents (category:read dans le contexte). + self::assertSame('NEGOCIANT', $data['categories'][0]['code']); + } + + public function testPostMainFormHasNoInlineContactFields(): void + { + // refonte-contact V0.2 : plus aucun champ de contact inline au POST. + $client = $this->createAdminClient(); + $cat = $this->supplierCategory('NEGOCIANT'); + + $data = $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'No Inline Co', + // Champs historiques : ignores par le denormaliseur. + 'firstName' => 'Ignored', + 'lastName' => 'Ignored', + 'phonePrimary' => '0612345678', + 'email' => 'ignored@test.fr', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) { + self::assertArrayNotHasKey($key, $data); + } + } + + // === RG-2.10 : categorie de type FOURNISSEUR === + + public function testPostWithNonFournisseurCategoryReturns422OnCategoriesPath(): void + { + $client = $this->createAdminClient(); + // createCategory() (parent) cree une categorie de type CLIENT -> interdite. + $clientTypedCategory = $this->createCategory('SECTEUR'); + + $response = $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'companyName' => 'Wrong Cat Type', + 'categories' => ['/api/categories/'.$clientTypedCategory->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(422); + $byPath = []; + foreach ($response->toArray(false)['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + // ERP-101 : la violation porte propertyPath=categories (mapping inline front). + self::assertArrayHasKey('categories', $byPath); + self::assertSame('Type de catégorie non autorisé (FOURNISSEUR attendu).', $byPath['categories']); + } + + // === RG-2.11 : unicite du nom de societe === + + public function testPostDuplicateCompanyNameReturns409(): void + { + $client = $this->createAdminClient(); + $this->seedSupplier('Dup Name Co'); + + $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Dup Name Co'), + ]); + + // RG-2.11 : doublon parmi les actifs -> 409 (index uq_supplier_company_name_active). + self::assertResponseStatusCodeSame(409); + } + + public function testPostSameNameAfterArchivingPreviousReturns201(): void + { + $client = $this->createAdminClient(); + // L'homonyme est archive -> hors index partiel : le nom redevient disponible. + $this->seedSupplier('Reuse After Archive', true); + + $client->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Reuse After Archive'), + ]); + + self::assertResponseStatusCodeSame(201); + } + + // === RG-2.14 : archivage (admin) === + + public function testAdminArchiveSetsArchivedAt(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Archive Me'); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + + self::assertResponseStatusCodeSame(200); + + $em = $this->getEm(); + $em->clear(); + $reloaded = $em->getRepository(Supplier::class)->find($seed->getId()); + self::assertNotNull($reloaded); + self::assertTrue($reloaded->isArchived()); + self::assertNotNull($reloaded->getArchivedAt(), 'RG-2.14 : archivedAt doit etre rempli a l\'archivage.'); + } + + public function testArchiveWithOtherFieldReturns422(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Archive Plus Field'); + + // RG-2.14 : une requete d'archivage ne modifie aucun autre champ. + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true, 'companyName' => 'Renamed While Archiving'], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testRestoreSetsArchivedAtNull(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedSupplier('Restore Me', true); + + $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => false], + ]); + + self::assertResponseStatusCodeSame(200); + + $em = $this->getEm(); + $em->clear(); + $reloaded = $em->getRepository(Supplier::class)->find($seed->getId()); + self::assertNotNull($reloaded); + self::assertFalse($reloaded->isArchived()); + self::assertNull($reloaded->getArchivedAt(), 'RG-2.15 : archivedAt repasse a null a la restauration.'); + } +} diff --git a/tests/Module/Commercial/Api/SupplierArchiveTest.php b/tests/Module/Commercial/Api/SupplierArchiveTest.php new file mode 100644 index 0000000..c736671 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierArchiveTest.php @@ -0,0 +1,36 @@ +createAdminClient(); + + $archived = $this->seedSupplier('Acme Conflict', true); + $this->seedSupplier('Acme Conflict', false); + + $client->request('PATCH', '/api/suppliers/'.$archived->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => false], + ]); + + self::assertResponseStatusCodeSame(409); + } +} diff --git a/tests/Module/Commercial/Api/SupplierAuditTest.php b/tests/Module/Commercial/Api/SupplierAuditTest.php new file mode 100644 index 0000000..bf86e03 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierAuditTest.php @@ -0,0 +1,140 @@ + ligne audit_log entity_type='commercial.Supplier' + * avec l'action et le diff attendus ; + * - RIB : `#[Auditable]` SANS `#[AuditIgnore]` sur iban/bic -> ces champs sensibles + * DOIVENT apparaitre dans le diff audite (decision § 2.7, miroir M1). + * + * @internal + */ +final class SupplierAuditTest extends AbstractSupplierApiTestCase +{ + private const string SUPPLIER_TYPE = 'commercial.Supplier'; + private const string RIB_TYPE = 'commercial.SupplierRib'; + + private ?Connection $auditConnection = null; + + protected function setUp(): void + { + parent::setUp(); + self::bootKernel(); + + /** @var Connection $conn */ + $conn = self::getContainer()->get('doctrine.dbal.audit_connection'); + $this->auditConnection = $conn; + } + + protected function tearDown(): void + { + if (null !== $this->auditConnection) { + $this->auditConnection->close(); + } + parent::tearDown(); + } + + public function testPostSupplierIsAudited(): void + { + $admin = $this->createAdminClient(); + $cat = $this->supplierCategory('NEGOCIANT'); + + $created = $admin->request('POST', '/api/suppliers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'companyName' => 'Audit Created Co', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ])->toArray(); + self::assertResponseStatusCodeSame(201); + + self::assertGreaterThanOrEqual( + 1, + $this->countAudit(self::SUPPLIER_TYPE, (string) $created['id'], 'create'), + 'Un audit_log "create" doit etre genere pour le fournisseur.', + ); + } + + public function testPatchSupplierIsAudited(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedSupplier('Audit Patch Co'); + + $admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Audit Patch Renamed'], + ]); + self::assertResponseStatusCodeSame(200); + + self::assertGreaterThanOrEqual( + 1, + $this->countAudit(self::SUPPLIER_TYPE, (string) $seed->getId(), 'update'), + 'Un audit_log "update" doit etre genere pour le PATCH.', + ); + } + + public function testArchiveSupplierIsAudited(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedSupplier('Audit Archive Co'); + + $admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + self::assertResponseStatusCodeSame(200); + + $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::SUPPLIER_TYPE, 'id' => (string) $seed->getId(), 'action' => 'update'], + ); + self::assertGreaterThanOrEqual(1, count($rows)); + + /** @var array $changes */ + $changes = json_decode((string) $rows[0]['changes'], true, flags: JSON_THROW_ON_ERROR); + self::assertArrayHasKey('isArchived', $changes, 'Le diff d\'archivage doit tracer isArchived.'); + } + + public function testRibCreateAuditIncludesIbanAndBic(): void + { + $admin = $this->createAdminClient(); + $seed = $this->seedSupplier('Rib Audit Host'); + + $rib = $admin->request('POST', '/api/suppliers/'.$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']); + } + + private function countAudit(string $type, string $id, string $action): int + { + return (int) $this->auditConnection->fetchOne( + 'SELECT COUNT(*) FROM audit_log WHERE entity_type = :type AND entity_id = :id AND action = :action', + ['type' => $type, 'id' => $id, 'action' => $action], + ); + } +} diff --git a/tests/Module/Commercial/Api/SupplierListTest.php b/tests/Module/Commercial/Api/SupplierListTest.php new file mode 100644 index 0000000..493e556 --- /dev/null +++ b/tests/Module/Commercial/Api/SupplierListTest.php @@ -0,0 +1,118 @@ +createAdminClient(); + $token = $this->token(); + + $this->seedSupplier($token.' Active'); + $this->seedSupplier($token.' Archived', true); + + $default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertSame(1, $default['totalItems'], 'RG-2.17 : archives exclus par defaut.'); + + $all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertSame(2, $all['totalItems'], 'RG-2.17 : ?includeArchived reintegre les archives.'); + } + + public function testListIsSortedByCompanyNameAsc(): void + { + $http = $this->createAdminClient(); + $token = $this->token(); + + // Inseres dans le desordre ; le tri par defaut doit remonter ALPHA avant ZETA. + $this->seedSupplier($token.' Zeta'); + $this->seedSupplier($token.' Alpha'); + + $names = array_map( + static fn (array $m): string => (string) $m['companyName'], + $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray()['member'], + ); + + self::assertCount(2, $names); + self::assertStringContainsString('ALPHA', $names[0], 'RG-2.17 : tri companyName ASC.'); + self::assertStringContainsString('ZETA', $names[1]); + } + + public function testPaginationDisabledReturnsFullCollection(): void + { + $http = $this->createAdminClient(); + $token = $this->token(); + + for ($i = 0; $i < 3; ++$i) { + $this->seedSupplier($token.' Item'.$i); + } + + // ?pagination=false : echappatoire pour alimenter un