Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 08281613b9 |
@@ -1,12 +0,0 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
connections:
|
||||
# Force le profiling DBAL en environnement de test independamment de
|
||||
# APP_DEBUG. Sans cela, la CI tourne en APP_DEBUG=0 (prod-like) et le
|
||||
# service `doctrine.debug_data_holder` n'est pas enregistre : le test
|
||||
# anti-N+1 (SupplierListTest::testListQueryCountDoesNotGrowWithRowCount)
|
||||
# qui compte les requetes via ce holder echoue alors en CI alors qu'il
|
||||
# passe en local (APP_DEBUG=1). Activer le profiling ici garde le test
|
||||
# actif precisement la ou il compte (CI), sans impacter la prod.
|
||||
default:
|
||||
profiling: true
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.92'
|
||||
app.version: '0.1.91'
|
||||
|
||||
@@ -711,108 +711,91 @@ 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 — RÉELLES, capturées ERP-92)
|
||||
### 4.0.bis Réponses JSON de référence (DoD — à confirmer sur l'API réelle)
|
||||
|
||||
> **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).
|
||||
> **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.
|
||||
|
||||
> **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?search=…` (liste, ADMIN — un membre) :
|
||||
`GET /api/suppliers` (liste, ADMIN) :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/Supplier",
|
||||
"@id": "/api/suppliers",
|
||||
"@type": "Collection",
|
||||
"totalItems": 1,
|
||||
"totalItems": 13,
|
||||
"member": [
|
||||
{
|
||||
"@id": "/api/suppliers/85",
|
||||
"@id": "/api/suppliers/1",
|
||||
"@type": "Supplier",
|
||||
"id": 85,
|
||||
"companyName": "DOD59393F 862875",
|
||||
"id": 1,
|
||||
"companyName": "RECYCLA SAS",
|
||||
"categories": [
|
||||
{"@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": "…"}
|
||||
{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
|
||||
],
|
||||
"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": [
|
||||
{"@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"}
|
||||
{"@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": "#…"}
|
||||
],
|
||||
"updatedAt": "2026-02-17T09:30:00+00:00",
|
||||
"isArchived": false
|
||||
}
|
||||
],
|
||||
"view": {"@id": "/api/suppliers?search=…", "@type": "PartialCollectionView"}
|
||||
"view": {
|
||||
"@id": "/api/suppliers?page=1",
|
||||
"@type": "PartialCollectionView",
|
||||
"first": "/api/suppliers?page=1",
|
||||
"last": "/api/suppliers?page=2",
|
||||
"next": "/api/suppliers?page=2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 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.
|
||||
> 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).
|
||||
|
||||
`GET /api/suppliers/85` (détail — user avec `accounting.view`) :
|
||||
`GET /api/suppliers/1` (détail — user avec `accounting.view`) :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/Supplier",
|
||||
"@id": "/api/suppliers/85",
|
||||
"@id": "/api/suppliers/1",
|
||||
"@type": "Supplier",
|
||||
"id": 85,
|
||||
"companyName": "DOD59393F 862875",
|
||||
"id": 1,
|
||||
"companyName": "RECYCLA SAS",
|
||||
"categories": [
|
||||
{"@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"}}
|
||||
{"@id": "/api/categories/12", "id": 12, "code": "NEGOCIANT", "name": "Négociant"}
|
||||
],
|
||||
"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"},
|
||||
"description": "…", "competitors": "…", "foundedAt": "2008-04-01",
|
||||
"employeesCount": 42, "revenueAmount": "1500000.00", "directorName": "…",
|
||||
"profitAmount": "120000.00", "volumeForecast": 8000,
|
||||
"contacts": [
|
||||
{"@id": "/api/supplier_contacts/39", "@type": "SupplierContact", "id": 39, "firstName": "Marie", "lastName": "Martin",
|
||||
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "email": "marie.martin@seed.test"}
|
||||
{"@id": "/api/supplier_contacts/1", "id": 1, "firstName": "Marie", "lastName": "Martin",
|
||||
"jobTitle": "Responsable achats", "phonePrimary": "0612345678", "phoneSecondary": null,
|
||||
"email": "marie.martin@recycla.fr"}
|
||||
],
|
||||
"addresses": [
|
||||
{"@id": "/api/supplier_addresses/33", "@type": "SupplierAddress", "id": 33, "addressType": "DEPART",
|
||||
"country": "France", "postalCode": "86000", "city": "Poitiers", "street": "12 rue des Acacias",
|
||||
{"@id": "/api/supplier_addresses/1", "id": 1, "addressType": "DEPART",
|
||||
"country": "France", "postalCode": "86000", "city": "Poitiers",
|
||||
"street": "12 rue des Acacias", "streetComplement": null,
|
||||
"bennes": 3, "triageProvider": true,
|
||||
"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"}]}
|
||||
"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"}]}
|
||||
],
|
||||
"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/27", "@type": "SupplierRib", "id": 27, "label": "Compte principal", "bic": "BNPAFRPPXXX", "iban": "FR1420041010050500013M02606"}
|
||||
{"@id": "/api/supplier_ribs/1", "id": 1, "label": "Compte principal",
|
||||
"bic": "SOGEFRPP", "iban": "FR7630003035400005000000123"}
|
||||
],
|
||||
"isArchived": false
|
||||
"isArchived": false, "archivedAt": null,
|
||||
"updatedAt": "2026-02-17T09:30:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
> 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).
|
||||
> 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.
|
||||
|
||||
### 4.0.ter Pièges de sérialisation CONSTATÉS sur le M1 réel → parade M2 (OBLIGATOIRE)
|
||||
|
||||
@@ -1063,7 +1046,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**
|
||||
- [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
|
||||
- [ ] **Réponses JSON RÉELLES** collées (§ 4.0.bis) — *en attente de `make start` + curl (DoD avant tickets front)*
|
||||
- [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)
|
||||
|
||||
@@ -229,7 +229,6 @@ 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
|
||||
|
||||
@@ -20,8 +20,7 @@ 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 ;
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -48,15 +47,15 @@ class Bank
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['bank:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['bank:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -20,8 +20,7 @@ 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 ;
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -48,15 +47,15 @@ class PaymentDelay
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['payment_delay:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -23,8 +23,7 @@ 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 ;
|
||||
* `supplier:read:accounting` dans la reponse Fournisseur (M2, ERP-92 — § 4.0).
|
||||
* `client:read:accounting` permet l'embarquement dans la reponse Client.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -51,15 +50,15 @@ class PaymentType
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['payment_type:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -24,8 +24,7 @@ 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 ; `supplier:read:accounting`
|
||||
* fait de meme dans la reponse Fournisseur (M2, ERP-92 — sinon IRI nu, § 4.0).
|
||||
* d'un Client (onglet Comptabilite) au lieu d'un IRI.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -55,15 +54,15 @@ class TvaMode
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 30)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?string $code = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting', 'supplier:read:accounting'])]
|
||||
#[Groups(['tva_mode:read', 'client:read:accounting'])]
|
||||
private ?string $label = null;
|
||||
|
||||
#[ORM\Column(options: ['default' => 0])]
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Category;
|
||||
use App\Module\Catalog\Domain\Entity\CategoryType;
|
||||
use App\Module\Commercial\Domain\Entity\Bank;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierAddress;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierContact;
|
||||
use App\Module\Commercial\Domain\Entity\SupplierRib;
|
||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Base des tests fonctionnels du repertoire fournisseurs (M2). Jumelle de la base
|
||||
* clients (M1), elle ajoute les factories specifiques fournisseur au-dessus de
|
||||
* {@see AbstractCommercialApiTestCase} (qui apporte deja createCategory sous le
|
||||
* type CLIENT, createUserWithPermission, authenticatedClient...).
|
||||
*
|
||||
* Donnees (RETEX M1 — pas de fixtures globales pour les tests) : chaque test seede
|
||||
* ses fournisseurs en base via les helpers ci-dessous, puis le tearDown les purge.
|
||||
* Les referentiels comptables (tva_mode / payment_delay / payment_type / bank) et
|
||||
* les categories FOURNISSEUR (Negociant, Cooperative...) sont seedes par les
|
||||
* fixtures applicatives (make test-db-setup) ; on les recupere par code.
|
||||
*
|
||||
* Categories : `supplierCategory('NEGOCIANT')` fetch-or-create une categorie de
|
||||
* type FOURNISSEUR (requis par RG-2.10) — fetch-or-create par code pour rester
|
||||
* idempotent et auto-suffisant (ne depend pas du seed, que d'autres tests de la
|
||||
* suite peuvent purger). Pour fabriquer une categorie d'un AUTRE type (test de
|
||||
* rejet RG-2.10), utiliser `createCategory()` du parent, qui cree sous CLIENT.
|
||||
*
|
||||
* Cleanup : le tearDown purge les fournisseurs AVANT le parent (qui supprime les
|
||||
* categories `test_cli_cat_*`) : la jointure supplier_category est ON DELETE
|
||||
* CASCADE cote supplier mais RESTRICT cote category — le DELETE DQL sur Supplier
|
||||
* declenche le cascade BDD sur supplier_category / _contact / _address, liberant
|
||||
* les categories pour la purge du parent.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
abstract class AbstractSupplierApiTestCase extends AbstractCommercialApiTestCase
|
||||
{
|
||||
protected const string LD = 'application/ld+json';
|
||||
protected const string MERGE = 'application/merge-patch+json';
|
||||
|
||||
/** IBAN/BIC valides (Assert\Iban / Assert\Bic) reutilises par les seeds. */
|
||||
protected const string VALID_IBAN = 'FR1420041010050500013M02606';
|
||||
protected const string VALID_BIC = 'BNPAFRPPXXX';
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->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<string, mixed>
|
||||
*/
|
||||
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<T> $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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexe les violations d'un corps de reponse 422 par propertyPath. Permet
|
||||
* d'asserter qu'un 422 porte bien sur le champ attendu (et n'est pas un 422
|
||||
* orthogonal) : un test qui se contente du code 422 passerait meme si la RG
|
||||
* visee etait cassee pour une autre raison.
|
||||
*
|
||||
* @param array<string, mixed> $body corps decode de la reponse (toArray(false))
|
||||
*
|
||||
* @return array<string, string> propertyPath => message
|
||||
*/
|
||||
protected function violationsByPath(array $body): array
|
||||
{
|
||||
$byPath = [];
|
||||
foreach ($body['violations'] ?? [] as $v) {
|
||||
$byPath[$v['propertyPath']] = $v['message'];
|
||||
}
|
||||
|
||||
return $byPath;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des RG comptables inter-champs portees par les Assert\Callback
|
||||
* de l'entite Supplier (M2, RG-2.07 / RG-2.08), via le PATCH de l'onglet
|
||||
* Comptabilite (groupe supplier:write:accounting). On asserte le code HTTP et le
|
||||
* propertyPath de la violation (consommable par extractApiViolations cote front,
|
||||
* ERP-101). Complete les tests unitaires SupplierValidationTest par la preuve HTTP.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierAccountingApiTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
// === RG-2.07 : Virement impose une banque ===
|
||||
|
||||
public function testVirementWithoutBankReturns422OnBankPath(): void
|
||||
{
|
||||
$client = $this->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);
|
||||
}
|
||||
|
||||
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels du formulaire principal fournisseur (M2, spec § 4.3 / § 4.4)
|
||||
* sur le CORPS JSON : creation (companyName + categories), normalisation serveur
|
||||
* (RG-2.12 UPPERCASE), categorie de type FOURNISSEUR (RG-2.10), unicite du nom
|
||||
* (RG-2.11) et archivage nominal (RG-2.14). Jumeau de ClientApiTest (M1).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierApiTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
// === POST formulaire principal ===
|
||||
|
||||
public function testPostMainFormUppercasesCompanyName(): void
|
||||
{
|
||||
$client = $this->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.
|
||||
$response = $client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true, 'companyName' => 'Renamed While Archiving'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Le 422 doit etre celui de RG-2.14 (archivage exclusif) et non un 422
|
||||
// orthogonal : on verifie le message porte par l'exception.
|
||||
self::assertStringContainsString('archivage', $response->getContent(false));
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests d'archivage / restauration fournisseur — trou 409 de restauration en
|
||||
* conflit d'unicite (M2, RG-2.15). Le nominal RG-2.14 (archive pose archivedAt)
|
||||
* et le 422 « archive + autre champ » sont couverts par SupplierApiTest. Jumeau
|
||||
* de ClientArchiveTest (M1).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierArchiveTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
/**
|
||||
* RG-2.15 : restaurer un fournisseur archive dont le nom a ete repris par un
|
||||
* fournisseur actif entre-temps doit echouer en 409 (index partiel
|
||||
* uq_supplier_company_name_active : un seul actif portant ce nom).
|
||||
*/
|
||||
public function testRestoreConflictReturns409(): void
|
||||
{
|
||||
$client = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Tests Audit du repertoire fournisseurs (M2, spec § 6). Couvre :
|
||||
* - POST / PATCH / archivage -> 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<string, mixed> $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<string, mixed> $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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels de la liste fournisseurs (M2, spec § 4.1 + RG-2.17 + règle
|
||||
* ABSOLUE n°13) : exclusion des archives par défaut, ?includeArchived, tri
|
||||
* companyName ASC, enveloppe Hydra (member/totalItems/view), échappatoire
|
||||
* ?pagination=false, et ANTI N+1 (le nombre de requêtes SQL de la liste ne croît
|
||||
* pas avec le nombre de lignes — fetch-joins/hydratation batchée § 2.12).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierListTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
public function testListExcludesArchivedByDefaultAndIncludesWithFlag(): void
|
||||
{
|
||||
$http = $this->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 <select> (regle n°13).
|
||||
$data = $http->request('GET', '/api/suppliers?search='.$token.'&pagination=false', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $data);
|
||||
self::assertCount(3, $data['member']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anti N+1 (§ 2.12) : le nombre de requetes SQL de la liste ne doit PAS croitre
|
||||
* avec le nombre de fournisseurs. On mesure pour N=2 puis N=4 (memes relations
|
||||
* embarquees : categories + addresses.sites) et on exige un compte IDENTIQUE —
|
||||
* preuve que l'hydratation est batchee (WHERE IN) et non par ligne.
|
||||
*/
|
||||
public function testListQueryCountDoesNotGrowWithRowCount(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$token = $this->token();
|
||||
|
||||
// Premiere mesure : 2 fournisseurs complets (avec adresses/sites/categories).
|
||||
$this->seedCompleteSupplier($token.' A');
|
||||
$this->seedCompleteSupplier($token.' B');
|
||||
$countFor2 = $this->countListQueries($token);
|
||||
|
||||
// Seconde mesure : 2 de plus (4 au total, tous sur la meme page).
|
||||
$this->seedCompleteSupplier($token.' C');
|
||||
$this->seedCompleteSupplier($token.' D');
|
||||
$countFor4 = $this->countListQueries($token);
|
||||
|
||||
self::assertSame(
|
||||
$countFor2,
|
||||
$countFor4,
|
||||
sprintf('Anti N+1 : le nombre de requetes liste doit etre constant (%d pour 2, %d pour 4).', $countFor2, $countFor4),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte les requetes SQL emises par UN GET liste filtre, via le data holder de
|
||||
* debug Doctrine (actif car kernel.debug=true en test). Le holder est remis a
|
||||
* zero juste avant la requete pour isoler ses requetes (hors login).
|
||||
*/
|
||||
private function countListQueries(string $token): int
|
||||
{
|
||||
$http = $this->createAdminClient();
|
||||
$holder = self::getContainer()->get('doctrine.debug_data_holder');
|
||||
$holder->reset();
|
||||
|
||||
$http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]]);
|
||||
|
||||
$data = $holder->getData();
|
||||
|
||||
return count($data['default'] ?? []);
|
||||
}
|
||||
|
||||
private function token(): string
|
||||
{
|
||||
return 'List'.substr(bin2hex(random_bytes(4)), 0, 8);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests de structure / migration M2 (§ 8.1). Vérifie au niveau du schéma Postgres :
|
||||
* - l'unique index partiel fonctionnel uq_supplier_company_name_active existe
|
||||
* (LOWER(company_name), partiel sur actifs non archivés / non supprimés —
|
||||
* RG-2.11), seule unicité de nom conservée ; pas d'index unique siren/email ;
|
||||
* - le type de catégorie FOURNISSEUR est présent (seedé migration + fixture).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierMigrationTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
public function testCompanyNameActivePartialIndexExistsExactlyOnce(): void
|
||||
{
|
||||
$rows = $this->supplierIndexes();
|
||||
|
||||
$companyNameIndexes = array_filter(
|
||||
$rows,
|
||||
static fn (array $r): bool => 'uq_supplier_company_name_active' === $r['indexname'],
|
||||
);
|
||||
|
||||
self::assertCount(1, $companyNameIndexes, 'Il doit exister exactement UN index uq_supplier_company_name_active.');
|
||||
|
||||
$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 testNoSirenOrEmailUniqueIndexOnSupplier(): void
|
||||
{
|
||||
$names = array_map(static fn (array $r): string => $r['indexname'], $this->supplierIndexes());
|
||||
|
||||
// § 2.6 : SIREN et email NON uniques sur le fournisseur.
|
||||
self::assertNotContains('uq_supplier_siren_active', $names);
|
||||
self::assertNotContains('uq_supplier_email_active', $names);
|
||||
}
|
||||
|
||||
public function testFournisseurCategoryTypeExists(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$count = (int) $this->getEm()->getConnection()->fetchOne(
|
||||
"SELECT COUNT(*) FROM category_type WHERE code = 'FOURNISSEUR'",
|
||||
);
|
||||
|
||||
self::assertSame(1, $count, 'Le type de categorie FOURNISSEUR doit etre present (migration + fixture).');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{indexname: string, indexdef: string}>
|
||||
*/
|
||||
private function supplierIndexes(): array
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
/** @var list<array{indexname: string, indexdef: string}> $rows */
|
||||
return $this->getEm()->getConnection()->fetchAllAssociative(
|
||||
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'supplier'",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
|
||||
/**
|
||||
* Mode strict PATCH multi-groupes fournisseur (M2, RG-2.16) — preuve fonctionnelle
|
||||
* HTTP, SANS dependre d'un role metier : un user portant
|
||||
* `commercial.suppliers.manage` mais PAS `commercial.suppliers.accounting.manage`
|
||||
* qui envoie un PATCH melant un champ principal (companyName) et un champ
|
||||
* comptable (siren) recoit 403 sur TOUT le payload — aucun champ applique (pas de
|
||||
* filtrage silencieux). Jumeau de ClientPatchStrictTest (M1).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierPatchStrictTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
public function testMixedGroupsPatchWithoutAccountingPermissionIsForbidden(): void
|
||||
{
|
||||
$seed = $this->seedSupplier('Strict Mix');
|
||||
$credentials = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => [
|
||||
'companyName' => 'Renamed Strict',
|
||||
'siren' => '123456789',
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-2.16 : 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(Supplier::class)->find($seed->getId());
|
||||
self::assertNotNull($reloaded);
|
||||
self::assertSame('STRICT MIX', $reloaded->getCompanyName());
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use ApiPlatform\Symfony\Bundle\Test\Client;
|
||||
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/**
|
||||
* Matrice RBAC complete du repertoire fournisseurs par role metier (spec-back M2
|
||||
* § 2.9 + ERP-90). Valide 200/403 par verbe et par onglet pour
|
||||
* bureau / compta / commerciale / usine, le gating des champs comptables en
|
||||
* lecture (omission de cle) et le durcissement RG-2.03 (Commerciale) au POST/PATCH.
|
||||
*
|
||||
* Les comptes demo et la matrice sont seedes via la commande reelle
|
||||
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente —
|
||||
* pas de mock de role. Jumeau de ClientRBACMatrixTest (M1).
|
||||
*
|
||||
* Matrice § 2.9 (ERP-90) — rappel :
|
||||
* - bureau : suppliers.view + manage (ni accounting, ni archive)
|
||||
* - compta : suppliers.view + accounting.view + accounting.manage (PAS manage)
|
||||
* - commerciale : suppliers.view + manage (PAS accounting), durcie RG-2.03
|
||||
* - usine : aucune permission (403 partout)
|
||||
* - archive : admin seul (aucun role metier)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierRBACMatrixTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
|
||||
// comptes demo). Exerce aussi le chemin de code prod.
|
||||
self::bootKernel();
|
||||
$application = new Application(self::$kernel);
|
||||
$application->setAutoExit(false);
|
||||
$exit = $application->run(
|
||||
new ArrayInput([
|
||||
'command' => 'app:seed-rbac',
|
||||
'--with-demo-users' => true,
|
||||
'--password' => self::PWD,
|
||||
]),
|
||||
new NullOutput(),
|
||||
);
|
||||
self::assertSame(
|
||||
0,
|
||||
$exit,
|
||||
'app:seed-rbac a echoue : les permissions commercial.suppliers.* sont-elles synchronisees (app:sync-permissions) ?',
|
||||
);
|
||||
|
||||
self::ensureKernelShutdown();
|
||||
}
|
||||
|
||||
public function testUsineIsForbiddenEverywhere(): void
|
||||
{
|
||||
$seed = $this->seedSupplier('Usine Target');
|
||||
$client = $this->authAs('usine');
|
||||
|
||||
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('GET', '/api/suppliers/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('POST', '/api/suppliers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Usine Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Renamed By Usine'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedSupplier('Bureau Target');
|
||||
$cat = $this->supplierCategory('NEGOCIANT');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : creation OK (bureau n'est pas gate par RG-2.03)
|
||||
$client->request('POST', '/api/suppliers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Bureau Created', $cat->getId()),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
|
||||
// manage : edition onglet principal OK
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Bureau Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testBureauDetailHasNoAccountingFields(): void
|
||||
{
|
||||
// Bureau a view mais PAS accounting.view : les champs comptables sont
|
||||
// ABSENTS du JSON (gating par omission, pas null).
|
||||
$supplier = $this->seedCompleteSupplier('Bureau Gating Co');
|
||||
$client = $this->authAs('bureau');
|
||||
|
||||
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Gating par omission sur l'ensemble des champs comptables (pas seulement
|
||||
// siren/ribs) : une regression reintroduisant accountNumber/nTva/tvaMode/
|
||||
// paymentType dans le groupe bureau serait sinon invisible.
|
||||
self::assertArrayNotHasKey('siren', $data);
|
||||
self::assertArrayNotHasKey('accountNumber', $data);
|
||||
self::assertArrayNotHasKey('nTva', $data);
|
||||
self::assertArrayNotHasKey('tvaMode', $data);
|
||||
self::assertArrayNotHasKey('paymentType', $data);
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
public function testComptaCanEditAccountingOnly(): void
|
||||
{
|
||||
$seed = $this->seedSupplier('Compta Target');
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : creation refusee
|
||||
$client->request('POST', '/api/suppliers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Compta Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// accounting.manage : edition onglet Comptabilite OK
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// PAS manage : edition onglet principal refusee (guardManage)
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Compta Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS manage : edition onglet Information refusee (guardManage)
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['description' => 'Une description'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testComptaDetailHasAccountingFields(): void
|
||||
{
|
||||
// Compta a accounting.view : siren + ribs presents dans le JSON.
|
||||
$supplier = $this->seedCompleteSupplier('Compta View Co');
|
||||
$client = $this->authAs('compta');
|
||||
|
||||
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('siren', $data);
|
||||
self::assertSame('123456789', $data['siren']);
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
}
|
||||
|
||||
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
|
||||
{
|
||||
$seed = $this->seedSupplier('Commerciale Target');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
// view
|
||||
$client->request('GET', '/api/suppliers', ['headers' => ['Accept' => self::LD]]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
|
||||
// manage : la creation passe la security d'operation (pas un 403 comme
|
||||
// Compta) mais bute sur RG-2.03 (onglet Information incomplet) -> 422.
|
||||
$response = $client->request('POST', '/api/suppliers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('Commerciale Post'),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
// Le 422 doit bien etre celui de RG-2.03 (onglet Information) et non un
|
||||
// 422 orthogonal : on exige une violation sur un champ de completude.
|
||||
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
|
||||
|
||||
// PAS accounting : edition onglet Comptabilite refusee
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['siren' => '123456789'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
// PAS archive : archivage refuse
|
||||
$client->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['isArchived' => true],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testCommercialeDetailHasNoAccountingFields(): void
|
||||
{
|
||||
$supplier = $this->seedCompleteSupplier('Commerciale Gating Co');
|
||||
$client = $this->authAs('commerciale');
|
||||
|
||||
$data = $client->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayNotHasKey('siren', $data);
|
||||
self::assertArrayNotHasKey('accountNumber', $data);
|
||||
self::assertArrayNotHasKey('nTva', $data);
|
||||
self::assertArrayNotHasKey('tvaMode', $data);
|
||||
self::assertArrayNotHasKey('paymentType', $data);
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
public function testRG203CommercialePostIncompleteIs422AdminIs201(): void
|
||||
{
|
||||
$cat = $this->supplierCategory('NEGOCIANT');
|
||||
|
||||
// RG-2.03 : Commerciale POST sans onglet Information complet -> 422.
|
||||
$commerciale = $this->authAs('commerciale');
|
||||
$response = $commerciale->request('POST', '/api/suppliers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('RG203 Commerciale', $cat->getId()),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
|
||||
|
||||
// Meme payload par un Admin (non gate par RG-2.03) -> 201.
|
||||
$admin = $this->createAdminClient();
|
||||
$admin->request('POST', '/api/suppliers', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => $this->validMainPayload('RG203 Admin', $cat->getId()),
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testRG203CommercialePatchIncompleteIs422(): void
|
||||
{
|
||||
// RG-2.03 : tout PATCH par une Commerciale exige l'Information complete.
|
||||
// Le fournisseur seede a une Information vide -> meme un PATCH du nom -> 422.
|
||||
$seed = $this->seedSupplier('Commerciale Patch Incomplete');
|
||||
$commerciale = $this->authAs('commerciale');
|
||||
|
||||
$response = $commerciale->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Commerciale Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('description', $this->violationsByPath($response->toArray(false)));
|
||||
|
||||
// Le meme PATCH par un Admin passe (non gate par RG-2.03) -> 200.
|
||||
$admin = $this->createAdminClient();
|
||||
$admin->request('PATCH', '/api/suppliers/'.$seed->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['companyName' => 'Admin Renamed'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
private function authAs(string $role): Client
|
||||
{
|
||||
return $this->authenticatedClient($role, self::PWD);
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
/**
|
||||
* Tests anti-regression du CONTRAT DE SERIALISATION du repertoire fournisseurs
|
||||
* (M2, spec-back § 4.0 / § 4.0.bis / § 4.0.ter). Jumeau du
|
||||
* {@see ClientSerializationContractTest} (M1), il reverifie sur le JSON reel les
|
||||
* 4 pieges silencieux constates en prod sur le M1 :
|
||||
* - #4 : fuite RIB (IBAN/BIC) vers un user sans accounting.view -> clé `ribs`
|
||||
* ABSENTE pour la Commerciale.
|
||||
* - #3 : booleens droppes (Groups sur la propriete `isX`, getter derivant `x`)
|
||||
* -> triageProvider (adresse) et isArchived (fournisseur) presents.
|
||||
* - #1 : categories embarquees sans code/name -> code + name presents en LISTE
|
||||
* ET DETAIL.
|
||||
* - #2 : sites embarques en IRI nu -> name + postalCode presents en LISTE
|
||||
* (via getSites()) ET DETAIL (addresses[].sites[]).
|
||||
* Plus l'enveloppe AP4 (member/totalItems/view sans prefixe hydra:, archives
|
||||
* exclus) et la suppression du contact inline (refonte-contact V0.2).
|
||||
*
|
||||
* REGLE D'OR : ces tests assertent sur le CORPS JSON reel, jamais sur les
|
||||
* annotations. Toute regression de groupe de serialisation casse ici.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierSerializationContractTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
// === #4 — Gating des RIB par accounting.view ===
|
||||
|
||||
public function testRibsPresentForAdminWithAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('Rib Admin Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Admin bypass RBAC -> accounting.view -> RIB embarques (label/bic/iban).
|
||||
self::assertArrayHasKey('ribs', $data);
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
self::assertSame('Compte principal', $data['ribs'][0]['label']);
|
||||
self::assertSame(self::VALID_IBAN, $data['ribs'][0]['iban']);
|
||||
self::assertSame(self::VALID_BIC, $data['ribs'][0]['bic']);
|
||||
}
|
||||
|
||||
public function testRibsAbsentForCommercialeWithoutAccountingView(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('Rib Commerciale Co');
|
||||
|
||||
// Commerciale : commercial.suppliers.view SANS accounting.view.
|
||||
$creds = $this->createUserWithPermission('commercial.suppliers.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// La clé `ribs` est ABSENTE (pas null) : le groupe supplier:read:accounting
|
||||
// n'est pas ajoute au contexte -> getRibs() jamais serialise. Fin de la
|
||||
// fuite IBAN/BIC (piege n°4 du M1).
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
// === #4.bis — Gating par OMISSION des scalaires comptables ===
|
||||
|
||||
public function testAccountingScalarsGatedByOmission(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('Compta Gating Co');
|
||||
$id = $supplier->getId();
|
||||
|
||||
// Admin : scalaires comptables presents.
|
||||
$admin = $this->createAdminClient();
|
||||
$adminData = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('siren', $adminData);
|
||||
self::assertSame('123456789', $adminData['siren']);
|
||||
self::assertArrayHasKey('accountNumber', $adminData);
|
||||
self::assertArrayHasKey('paymentType', $adminData);
|
||||
|
||||
// Commerciale : scalaires comptables ABSENTS (omission, pas null).
|
||||
$creds = $this->createUserWithPermission('commercial.suppliers.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$data = $http->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayNotHasKey('siren', $data);
|
||||
self::assertArrayNotHasKey('accountNumber', $data);
|
||||
self::assertArrayNotHasKey('nTva', $data);
|
||||
self::assertArrayNotHasKey('tvaMode', $data);
|
||||
self::assertArrayNotHasKey('paymentType', $data);
|
||||
self::assertArrayNotHasKey('ribs', $data);
|
||||
}
|
||||
|
||||
// === Refs comptables embarquees {id,label} et non IRI nu (ERP-92) ===
|
||||
|
||||
public function testAccountingReferentialsEmbedIdAndLabel(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
// Reglement Virement -> banque renseignee : on couvre les 4 referentiels.
|
||||
$supplier = $this->seedCompleteSupplier('Refs Embed Co', 'VIREMENT');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Avant fix ERP-92 : ces refs sortaient en IRI nu ("/api/tva_modes/30")
|
||||
// car les entites partagees ne portaient que `client:read:accounting` (M1),
|
||||
// pas `supplier:read:accounting`. Apres fix : objet {id, label} embarque
|
||||
// (le front consultation/edition affiche le libelle sans fetch — § 4.0).
|
||||
foreach (['tvaMode', 'paymentDelay', 'paymentType', 'bank'] as $ref) {
|
||||
self::assertArrayHasKey($ref, $data, sprintf('Le ref comptable "%s" doit etre present.', $ref));
|
||||
self::assertIsArray($data[$ref], sprintf('Le ref "%s" doit etre un objet embarque, pas un IRI nu.', $ref));
|
||||
self::assertArrayHasKey('id', $data[$ref]);
|
||||
self::assertArrayHasKey('label', $data[$ref]);
|
||||
self::assertNotSame('', (string) $data[$ref]['label']);
|
||||
}
|
||||
|
||||
// paymentType embarque aussi son code (logique front VIREMENT/LCR).
|
||||
self::assertArrayHasKey('code', $data['paymentType']);
|
||||
self::assertSame('VIREMENT', $data['paymentType']['code']);
|
||||
}
|
||||
|
||||
// === #3 — Booleens presents dans le JSON (triageProvider + isArchived) ===
|
||||
|
||||
public function testAddressTriageProviderBooleanIsPresentInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('Bool Addr Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('addresses', $data);
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
$address = $data['addresses'][0];
|
||||
|
||||
// Le bug M1 droppait TOTALEMENT la cle (Groups sur la propriete `triageProvider`,
|
||||
// getter derivant `triage`). Apres parade (Groups + SerializedName sur le
|
||||
// getter isTriageProvider), la cle est presente ET typee bool `true`.
|
||||
self::assertArrayHasKey('triageProvider', $address);
|
||||
self::assertTrue($address['triageProvider']);
|
||||
}
|
||||
|
||||
public function testSupplierIsArchivedBooleanIsPresentInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('Bool Archived Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// isArchived expose via Groups + SerializedName('isArchived') sur le getter :
|
||||
// sans cela Symfony exposerait la cle "archived" et la droppait (piege n°3 M1).
|
||||
self::assertArrayHasKey('isArchived', $data);
|
||||
self::assertFalse($data['isArchived']);
|
||||
}
|
||||
|
||||
// === #1 — Embed code/name des Category (liste ET detail) ===
|
||||
|
||||
public function testCategoriesEmbedCodeAndNameInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('Embed Cat Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertNotEmpty($data['categories']);
|
||||
$category = $data['categories'][0];
|
||||
// Avant correctif M1 : seuls @id/@type (category:read absent du contexte).
|
||||
// Apres : code + name embarques.
|
||||
self::assertArrayHasKey('code', $category);
|
||||
self::assertArrayHasKey('name', $category);
|
||||
self::assertSame('NEGOCIANT', $category['code']);
|
||||
|
||||
// Categories d'adresse aussi (category:read dans le contexte du detail).
|
||||
self::assertArrayHasKey('categories', $data['addresses'][0]);
|
||||
self::assertNotEmpty($data['addresses'][0]['categories']);
|
||||
self::assertArrayHasKey('code', $data['addresses'][0]['categories'][0]);
|
||||
}
|
||||
|
||||
public function testCategoriesEmbedCodeAndNameInList(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'CatList'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$supplier = $this->seedCompleteSupplier($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $supplier->getId());
|
||||
self::assertNotNull($row, 'Le fournisseur seede doit apparaitre dans la liste filtree.');
|
||||
self::assertNotEmpty($row['categories']);
|
||||
self::assertArrayHasKey('code', $row['categories'][0]);
|
||||
self::assertArrayHasKey('name', $row['categories'][0]);
|
||||
self::assertSame('NEGOCIANT', $row['categories'][0]['code']);
|
||||
}
|
||||
|
||||
// === #2 — Embed name/postalCode des Site (liste via getSites + detail) ===
|
||||
|
||||
public function testSitesEmbedNameAndPostalCodeInList(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'SiteList'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$supplier = $this->seedCompleteSupplier($token);
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$list = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$row = $this->memberById($list, (int) $supplier->getId());
|
||||
self::assertNotNull($row);
|
||||
// sites agreges depuis les adresses via getSites() : objet Site entier
|
||||
// (name + postalCode), pas un IRI nu (piege n°2 M1). Multi-sites (>= 2).
|
||||
self::assertArrayHasKey('sites', $row);
|
||||
self::assertGreaterThanOrEqual(2, count($row['sites']));
|
||||
self::assertArrayHasKey('name', $row['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $row['sites'][0]);
|
||||
self::assertNotSame('', (string) $row['sites'][0]['name']);
|
||||
}
|
||||
|
||||
public function testSitesEmbedNameAndPostalCodeInDetail(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('Site Detail Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$address = $data['addresses'][0];
|
||||
|
||||
self::assertArrayHasKey('sites', $address);
|
||||
self::assertGreaterThanOrEqual(2, count($address['sites']), 'L\'adresse seedee est multi-sites.');
|
||||
self::assertArrayHasKey('name', $address['sites'][0]);
|
||||
self::assertArrayHasKey('postalCode', $address['sites'][0]);
|
||||
self::assertNotSame('', (string) $address['sites'][0]['name']);
|
||||
}
|
||||
|
||||
// === Detail : sous-collections embarquees ===
|
||||
|
||||
public function testDetailEmbedsContactsAddressesRibs(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('Embed Subres Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertNotEmpty($data['contacts']);
|
||||
self::assertSame('Marie', $data['contacts'][0]['firstName']);
|
||||
self::assertSame('Martin', $data['contacts'][0]['lastName']);
|
||||
self::assertArrayHasKey('email', $data['contacts'][0]);
|
||||
|
||||
self::assertNotEmpty($data['addresses']);
|
||||
self::assertSame('DEPART', $data['addresses'][0]['addressType']);
|
||||
|
||||
self::assertNotEmpty($data['ribs']);
|
||||
}
|
||||
|
||||
// === refonte-contact V0.2 : plus de contact inline sur le fournisseur ===
|
||||
|
||||
public function testSupplierHasNoInlineContactFields(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$supplier = $this->seedCompleteSupplier('No Inline Contact Co');
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$data = $http->request('GET', '/api/suppliers/'.$supplier->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Les champs de contact vivent UNIQUEMENT sous contacts[] (refonte-contact).
|
||||
foreach (['firstName', 'lastName', 'phonePrimary', 'phoneSecondary', 'email'] as $key) {
|
||||
self::assertArrayNotHasKey($key, $data, sprintf('Le champ inline "%s" ne doit plus exister au niveau du fournisseur.', $key));
|
||||
}
|
||||
}
|
||||
|
||||
// === Enveloppe AP4 (sans prefixe hydra:) + exclusion des archives ===
|
||||
|
||||
public function testCollectionEnvelopeShapeAndArchivedExcluded(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$http = $this->createAdminClient();
|
||||
$token = 'EnvCheck'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
|
||||
$this->seedSupplier($token.' Active');
|
||||
$this->seedSupplier($token.' Archived', true);
|
||||
|
||||
// Liste par defaut filtree sur le token : enveloppe member/totalItems sans
|
||||
// prefixe hydra:, archive EXCLU du totalItems (RG-2.17).
|
||||
$default = $http->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
self::assertArrayHasKey('member', $default);
|
||||
self::assertArrayHasKey('totalItems', $default);
|
||||
self::assertArrayNotHasKey('hydra:member', $default);
|
||||
self::assertArrayNotHasKey('hydra:totalItems', $default);
|
||||
self::assertSame(1, $default['totalItems'], 'Archive exclu du totalItems par defaut.');
|
||||
|
||||
// includeArchived : l'archive reintegre le total.
|
||||
$all = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertSame(2, $all['totalItems']);
|
||||
|
||||
// `view` (PartialCollectionView) sans prefixe hydra:.
|
||||
$paged = $http->request('GET', '/api/suppliers?search='.$token.'&includeArchived=true&itemsPerPage=1', ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
self::assertArrayHasKey('view', $paged);
|
||||
self::assertArrayNotHasKey('hydra:view', $paged);
|
||||
}
|
||||
|
||||
/**
|
||||
* DoD (§ 4.0.bis) : capture des reponses JSON REELLES (liste + detail admin +
|
||||
* detail commerciale) pour les coller dans la spec avant de lancer les tickets
|
||||
* front. Le test asserte la forme ; si la variable d'env SUPPLIER_DOD_DUMP est
|
||||
* positionnee, il ecrit aussi les 3 corps formates sous /tmp pour copie.
|
||||
*/
|
||||
public function testDodReferenceJsonShape(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
|
||||
$token = 'DoD'.substr(bin2hex(random_bytes(3)), 0, 6);
|
||||
$supplier = $this->seedCompleteSupplier($token);
|
||||
$id = (int) $supplier->getId();
|
||||
|
||||
$admin = $this->createAdminClient();
|
||||
$list = $admin->request('GET', '/api/suppliers?search='.$token, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
$detailAdmin = $admin->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
$creds = $this->createUserWithPermission('commercial.suppliers.view');
|
||||
$commerciale = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
$detailCommerciale = $commerciale->request('GET', '/api/suppliers/'.$id, ['headers' => ['Accept' => self::LD]])->toArray();
|
||||
|
||||
// Forme minimale attendue (la DoD valide que tout champ front est present).
|
||||
self::assertArrayHasKey('member', $list);
|
||||
self::assertArrayHasKey('siren', $detailAdmin);
|
||||
self::assertArrayHasKey('ribs', $detailAdmin);
|
||||
self::assertArrayNotHasKey('siren', $detailCommerciale);
|
||||
self::assertArrayNotHasKey('ribs', $detailCommerciale);
|
||||
|
||||
if (false !== getenv('SUPPLIER_DOD_DUMP')) {
|
||||
$flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||
file_put_contents('/tmp/supplier-dod-list.json', json_encode($list, $flags));
|
||||
file_put_contents('/tmp/supplier-dod-detail-admin.json', json_encode($detailAdmin, $flags));
|
||||
file_put_contents('/tmp/supplier-dod-detail-commerciale.json', json_encode($detailCommerciale, $flags));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrouve un membre de la collection par son id (liste filtree).
|
||||
*
|
||||
* @param array<string, mixed> $collection
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function memberById(array $collection, int $id): ?array
|
||||
{
|
||||
foreach ($collection['member'] ?? [] as $member) {
|
||||
if (($member['id'] ?? null) === $id) {
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
|
||||
/**
|
||||
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du fournisseur
|
||||
* (M2, spec § 4.5). Couvrent : normalisation contact (RG-2.12), RG-2.04 (prenom
|
||||
* OU nom), RG-2.05 (code postal), RG-2.06 (>= 1 site), RG-2.09 (enum addressType),
|
||||
* RG-2.10 (categorie FOURNISSEUR sur adresse), RG-2.08 (DELETE dernier RIB sous
|
||||
* LCR -> 409), DELETE contact libre au M2 (pas de garde « dernier contact ») et le
|
||||
* gating comptable des RIB (manage seul -> 403). Jumeau de ClientSubResourceApiTest.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierSubResourceApiTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
// === Contacts ===
|
||||
|
||||
public function testPostContactNormalizesFields(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Contact Host');
|
||||
|
||||
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'firstName' => 'JEAN',
|
||||
'lastName' => 'dupont',
|
||||
'phonePrimary' => '06.12.34.56.78',
|
||||
'email' => 'Jean.DUPONT@ACME.FR',
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
// RG-2.12 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
|
||||
self::assertSame('Jean', $data['firstName']);
|
||||
self::assertSame('Dupont', $data['lastName']);
|
||||
self::assertSame('0612345678', $data['phonePrimary']);
|
||||
self::assertSame('jean.dupont@acme.fr', $data['email']);
|
||||
}
|
||||
|
||||
public function testPostContactWithoutNameReturns422OnFirstNamePath(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Contact No Name');
|
||||
|
||||
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['jobTitle' => 'Directeur'],
|
||||
]);
|
||||
|
||||
// RG-2.04 (prenom OU nom obligatoire) -> 422 rattachee a firstName.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
$byPath = $this->violationsByPath($response->toArray(false));
|
||||
self::assertArrayHasKey('firstName', $byPath);
|
||||
}
|
||||
|
||||
public function testPostContactOnMissingSupplierReturns404(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
|
||||
$client->request('POST', '/api/suppliers/999999/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => ['firstName' => 'Orphan'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testDeleteLastContactReturns204(): void
|
||||
{
|
||||
// M2 : pas de garde « dernier contact » (RG-2.13 front-driven) — la
|
||||
// suppression du dernier contact est libre (204), contrairement au M1.
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Contact Solo');
|
||||
$contact = $this->addContact($seed, 'Unique', 'Contact');
|
||||
|
||||
$client->request('DELETE', '/api/supplier_contacts/'.$contact->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testContactWriteWithoutManageReturns403(): void
|
||||
{
|
||||
// Un user sans aucune permission suppliers -> 403 sur la sous-ressource.
|
||||
$seed = $this->seedSupplier('Contact Forbidden');
|
||||
$creds = $this->createUserWithPermission('core.users.view');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('POST', '/api/suppliers/'.$seed->getId().'/contacts', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['firstName' => 'Nope'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// === Adresses ===
|
||||
|
||||
public function testPostAddressWithValidPayloadReturns201(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Host');
|
||||
$category = $this->supplierCategory('NEGOCIANT');
|
||||
|
||||
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'addressType' => 'DEPART',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$category->getId()],
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('DEPART', $data['addressType']);
|
||||
}
|
||||
|
||||
public function testPostAddressWithoutSiteReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address No Site');
|
||||
|
||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'addressType' => 'DEPART',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-2.06 (Assert\Count min 1 sur sites).
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostAddressWithInvalidPostalCodeReturns422(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Bad CP');
|
||||
|
||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'addressType' => 'DEPART',
|
||||
'postalCode' => '123',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-2.05 (Assert\Regex ^[0-9]{4,5}$).
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testPostAddressWithIncoherentCityAndPostalCodeReturns201(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Incoherent');
|
||||
|
||||
// RG-2.05 : pas de controle strict de coherence CP/ville cote serveur.
|
||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'addressType' => 'DEPART',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Marseille',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
}
|
||||
|
||||
public function testPostAddressWithInvalidTypeReturns422(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Bad Type');
|
||||
|
||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'addressType' => 'INVALID',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-2.09 (Assert\Choice PROSPECT|DEPART|RENDU).
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* RG-2.09 : les 3 valeurs valides de addressType sont acceptees.
|
||||
*/
|
||||
public function testPostAddressWithEachValidTypeReturns201(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Types');
|
||||
$siteIri = $this->firstSiteIri();
|
||||
|
||||
foreach (['PROSPECT', 'DEPART', 'RENDU'] as $type) {
|
||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'addressType' => $type,
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$siteIri],
|
||||
],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(201, sprintf('addressType=%s doit etre accepte.', $type));
|
||||
}
|
||||
}
|
||||
|
||||
public function testPostAddressWithNonFournisseurCategoryReturns422(): void
|
||||
{
|
||||
$this->skipIfSitesModuleDisabled();
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Address Bad Cat');
|
||||
// categorie de type CLIENT -> interdite sur une adresse fournisseur.
|
||||
$clientTypedCategory = $this->createCategory('SECTEUR');
|
||||
|
||||
$response = $client->request('POST', '/api/suppliers/'.$seed->getId().'/addresses', [
|
||||
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
|
||||
'json' => [
|
||||
'addressType' => 'DEPART',
|
||||
'postalCode' => '86100',
|
||||
'city' => 'Châtellerault',
|
||||
'street' => '1 rue du Test',
|
||||
'sites' => [$this->firstSiteIri()],
|
||||
'categories' => ['/api/categories/'.$clientTypedCategory->getId()],
|
||||
],
|
||||
]);
|
||||
|
||||
// RG-2.10 -> 422 rattachee a categories.
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
|
||||
}
|
||||
|
||||
// === RIBs ===
|
||||
|
||||
public function testPostRibByAdminReturns201(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Rib Host');
|
||||
|
||||
$data = $client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => [
|
||||
'label' => 'Compte principal',
|
||||
'bic' => self::VALID_BIC,
|
||||
'iban' => self::VALID_IBAN,
|
||||
],
|
||||
])->toArray();
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
self::assertSame('Compte principal', $data['label']);
|
||||
}
|
||||
|
||||
public function testPostRibWithInvalidIbanReturns422(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Rib Bad Iban');
|
||||
|
||||
$client->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
|
||||
]);
|
||||
|
||||
self::assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testDeleteRibNonLcrReturns204(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Rib Non LCR');
|
||||
$rib = $this->addRib($seed);
|
||||
|
||||
$client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
|
||||
|
||||
self::assertResponseStatusCodeSame(204);
|
||||
}
|
||||
|
||||
public function testDeleteLastRibUnderLcrReturns409(): void
|
||||
{
|
||||
$client = $this->createAdminClient();
|
||||
$seed = $this->seedSupplier('Rib LCR Solo');
|
||||
$rib = $this->addRib($seed);
|
||||
// Passe le fournisseur en LCR (seed direct).
|
||||
$em = $this->getEm();
|
||||
$managed = $em->getRepository(Supplier::class)->find($seed->getId());
|
||||
$managed->setPaymentType($this->paymentType('LCR'));
|
||||
$em->flush();
|
||||
|
||||
$client->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
|
||||
|
||||
// RG-2.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
|
||||
self::assertResponseStatusCodeSame(409);
|
||||
}
|
||||
|
||||
public function testRibWriteWithoutAccountingManageReturns403(): void
|
||||
{
|
||||
// Un user portant seulement suppliers.manage (sans accounting.manage) ne
|
||||
// peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
|
||||
$seed = $this->seedSupplier('Rib Forbidden');
|
||||
$rib = $this->addRib($seed);
|
||||
$creds = $this->createUserWithPermission('commercial.suppliers.manage');
|
||||
$http = $this->authenticatedClient($creds['username'], $creds['password']);
|
||||
|
||||
$http->request('POST', '/api/suppliers/'.$seed->getId().'/ribs', [
|
||||
'headers' => ['Content-Type' => self::LD],
|
||||
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$http->request('PATCH', '/api/supplier_ribs/'.$rib->getId(), [
|
||||
'headers' => ['Content-Type' => self::MERGE],
|
||||
'json' => ['label' => 'Y'],
|
||||
]);
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
|
||||
$http->request('DELETE', '/api/supplier_ribs/'.$rib->getId());
|
||||
self::assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
// violationsByPath() : helper mutualise dans AbstractSupplierApiTestCase.
|
||||
|
||||
private function firstSiteIri(): string
|
||||
{
|
||||
$site = $this->getEm()->getRepository(Site::class)->findOneBy([]);
|
||||
self::assertNotNull($site, 'Aucun site seede : impossible de tester les adresses.');
|
||||
|
||||
return '/api/sites/'.$site->getId();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Commercial\Api;
|
||||
|
||||
use App\Module\Commercial\Domain\Entity\Supplier;
|
||||
|
||||
/**
|
||||
* Tests d'unicite fournisseur (M2, RG-2.11). Le doublon de companyName (409) est
|
||||
* couvert par {@see SupplierApiTest::testPostDuplicateCompanyNameReturns409}. Ce
|
||||
* fichier prouve l'envers de la decision § 2.6 : SIREN NON unique (etablissements
|
||||
* multiples). Jumeau de ClientUniquenessTest (M1).
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class SupplierUniquenessTest extends AbstractSupplierApiTestCase
|
||||
{
|
||||
public function testDuplicateSirenIsAllowed(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$em = $this->getEm();
|
||||
|
||||
$one = $this->seedSupplier('Siren Share One');
|
||||
$two = $this->seedSupplier('Siren Share Two');
|
||||
|
||||
// Le SIREN n'est pas ecrivable au POST (groupe accounting) : seed direct.
|
||||
$one->setSiren('123456789');
|
||||
$two->setSiren('123456789');
|
||||
$em->flush();
|
||||
|
||||
// Aucune exception : pas d'index unique sur siren (§ 2.6).
|
||||
self::assertSame('123456789', $em->getRepository(Supplier::class)->find($one->getId())?->getSiren());
|
||||
self::assertSame('123456789', $em->getRepository(Supplier::class)->find($two->getId())?->getSiren());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user