From ab154524590d05f03f99a55fbf00f99806f33994 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 18 Jun 2026 12:01:58 +0200 Subject: [PATCH] =?UTF-8?q?test(logistique)=20:=20tests=20PHPUnit=20RG-5.0?= =?UTF-8?q?1=E2=86=925.10=20+=20capture=20contrat=20JSON=20(ERP-187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Couverture des règles de gestion du M5 (tickets de pesée) et capture de la réponse JSON réelle (DoD § 4.0.bis) avant les écrans front. Tests unitaires (Processor/Normalizer/Callback, sans BDD ni HTTP) : - NetWeightTest (RG-5.05) : net = plein − vide, null si pesée manquante, recalcul PATCH. - CounterpartyValidationTest (RG-5.03) : présence par branche (propertyPath) + exclusivité. - ImmatriculationNormalizationTest (RG-5.01/5.10) : masque XX-000-XX, « Tout format », 422. Tests fonctionnels (API réelle) : - WeighingTicketNumberingTest (RG-5.02/5.09) : format {siteCode}-TP-{NNNN}, séquence par site, isolation inter-sites, immuabilité numéro/site au PATCH. - WeighingTicketSerializationContractTest (DoD § 4.0.bis) : 4 pièges (client embarqué, plateFreeFormat présent, number formaté, netWeight = full − empty) + dump JSON. - WeighingTicketRBACMatrixTest (§ 5.2) : admin/bureau/usine OK, compta/commerciale 403, anonyme 401. DSD/stub/reading déjà couverts (ERP-184/185). spec-back.md § 4.0.bis : JSON réel collé. --- docs/specs/M5-tickets-pesee/spec-back.md | 135 ++++++++---- .../Api/AbstractWeighingTicketApiTestCase.php | 192 ++++++++++++++++++ .../Api/WeighingTicketNumberingTest.php | 92 +++++++++ .../Api/WeighingTicketRBACMatrixTest.php | 121 +++++++++++ ...eighingTicketSerializationContractTest.php | 101 +++++++++ .../ImmatriculationNormalizationTest.php | 162 +++++++++++++++ .../Processor/CounterpartyValidationTest.php | 187 +++++++++++++++++ .../State/Processor/NetWeightTest.php | 130 ++++++++++++ 8 files changed, 1084 insertions(+), 36 deletions(-) create mode 100644 tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php create mode 100644 tests/Module/Logistique/Api/WeighingTicketNumberingTest.php create mode 100644 tests/Module/Logistique/Api/WeighingTicketRBACMatrixTest.php create mode 100644 tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php create mode 100644 tests/Module/Logistique/Application/Service/ImmatriculationNormalizationTest.php create mode 100644 tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php create mode 100644 tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/NetWeightTest.php diff --git a/docs/specs/M5-tickets-pesee/spec-back.md b/docs/specs/M5-tickets-pesee/spec-back.md index d93a60d..ef39e98 100644 --- a/docs/specs/M5-tickets-pesee/spec-back.md +++ b/docs/specs/M5-tickets-pesee/spec-back.md @@ -172,14 +172,16 @@ Pattern Starseed standard (miroir M1→M4) : - `WeighingTicket implements TimestampableInterface, BlamableInterface` + `use TimestampableBlamableTrait` (4 colonnes standard). - **Libellé i18n** (règle ABSOLUE backend — `AuditableEntitiesHaveI18nLabelTest`) : ajouter `audit.entity.logistique_weighingticket` dans `frontend/i18n/locales/fr.json` (clé = `strtolower(module)` + `_` + `strtolower(Entity)`). -### 2.12 Impression du ticket / bon de pesée (RG-5.08) +### 2.12 Bon de pesée — PDF généré côté serveur via template Twig (RG-5.08) -> **OWNER : Tristan.** La **réalisation du bon d'impression** (gabarit du ticket de pesée, mise en page, déclenchement de l'impression) est **prise en charge par Tristan lui-même** — hors de la découpe back/front standard du M5. Cette spec en pose **le contrat attendu** (déclencheur, contenu, données disponibles) pour qu'il puisse s'y brancher sans rétro-spec. +> **DÉCISION Matthieu (17/06)** : le **bon de pesée est généré côté back** par un **template Twig → PDF** (et non un gabarit imprimé par le navigateur). **OWNER : Tristan** (ticket back dédié, cf. § 10). Cette spec en pose le contrat (endpoint, contenu, données). Contrat attendu : -- **Déclencheur** : à la **validation** (création), l'API renvoie le ticket complet ; le front ouvre une **modal d'impression**. En **modification**, un bouton **« Imprimer »** est disponible (absent à l'ajout — docx / RG-5.08). -- **Contenu minimal du bon** : numéro (`{siteCode}-TP-{NNNN}`), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, **pesée à vide** (date/poids/DSD), **pesée à plein** (date/poids/DSD), **poids net** (= plein − vide), date d'édition. -- **Données** : toutes disponibles dans la réponse `GET /api/weighing_tickets/{id}` (§ 4.0) — aucun champ supplémentaire requis côté API. Si Tristan opte pour un **PDF serveur**, prévoir l'endpoint `GET /api/weighing_tickets/{id}/print.pdf` (HP-M5-04) ; sinon impression navigateur d'un gabarit front. +- **Endpoint** : `GET /api/weighing_tickets/{id}/print.pdf` (opération API Platform dédiée, **pas de controller** — provider renvoyant un binaire). Sécurité `is_granted('logistique.weighing_tickets.view')`. Réponse `Content-Type: application/pdf` (inline). +- **Rendu** : un template **Twig** (`templates/logistique/weighing_ticket_print.html.twig`) hydraté avec le ticket → converti en PDF via le générateur PDF du projet (ex. Dompdf / wkhtmltopdf / Gotenberg — s'aligner sur l'existant ; sinon proposer une lib et la cadrer avec Matthieu). +- **Contenu du bon** : numéro (`{siteCode}-TP-{NNNN}`), site, contrepartie (Client / Fournisseur / Autre + libellé), immatriculation, **pesée à vide** (date/poids/DSD), **pesée à plein** (date/poids/DSD), **poids net** (= plein − vide), date d'édition. (En-tête / logo / mentions = à caler par Tristan.) +- **Données** : toutes déjà disponibles sur le ticket (mêmes champs que `GET /api/weighing_tickets/{id}` § 4.0) — aucun champ API supplémentaire requis. +- **Déclencheurs front** (RG-5.08) : à la **validation** (création), le front ouvre l'aperçu/PDF servi par cet endpoint ; en **modification**, le bouton **« Imprimer »** ouvre le même PDF (absent à l'ajout). ### 2.13 Pas d'archive ; soft delete préparé non exposé @@ -504,17 +506,19 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface **DÉTAIL — maillons** : scalaires + `emptyDate/emptyWeight/emptyDsd/...` + `full*` ∈ `weighing_ticket:item:read` ; `client`/`supplier`/`site` embarqués (`client:read`/`supplier:read`/`site:read`). -### 4.0.bis Réponse JSON de référence (DoD — à CAPTURER sur l'API réelle) +### 4.0.bis Réponse JSON de référence (DoD — CAPTURÉE sur l'API réelle ✅) -> **Definition of Done** (miroir M2/M3/M4) : avant les écrans front, **capturer la réponse RÉELLE** via un test PHPUnit (`WeighingTicketSerializationContractTest`, ticket complet seedé : contrepartie Client, pesée vide + plein) et la coller ici. Toute donnée affichée par le front DOIT apparaître dans ce JSON. +> **Definition of Done** (miroir M2/M3/M4) : ✅ **FAIT (ERP-187)**. Le JSON ci-dessous est la réponse **RÉELLE** capturée par le test `WeighingTicketSerializationContractTest::testListAndDetailSerializationContract` (ticket créé via `POST /api/weighing_tickets` — numérotation serveur réelle — contrepartie Client, pesée vide + plein AUTO). Re-capturable : `WEIGHING_TICKET_DOD_DUMP=1` → `/tmp/weighing-ticket-dod-{list,detail}.json`. **Feu vert front.** Toute donnée affichée par le front DOIT apparaître dans ce JSON. > -> **Pièges à re-tester** : -> 1. `client` / `supplier` doivent sortir en **objet embarqué**, pas en IRI nu → read-groups `client:read`/`supplier:read`. -> 2. Booléen `plateFreeFormat` : clé présente (piège #3 M1 → getter + `SerializedName` si besoin). -> 3. `number` présent et formaté `{siteCode}-TP-{NNNN}`. -> 4. `netWeight` cohérent = `full - empty` (plein − vide, RG-5.05). +> **Pièges re-testés — tous VERTS** (assertions dans le test) : +> 1. ✅ `client` sort en **objet embarqué** (`client:read`), pas en IRI nu ; `supplier` **omis car null** (`skip_null_values` — jamais un IRI nu). Sur une contrepartie Fournisseur, `supplier` sortirait symétriquement en objet (`supplier:read`). +> 2. ✅ Booléen `plateFreeFormat` : **clé présente** (getter `isPlateFreeFormat()` + `SerializedName('plateFreeFormat')`). +> 3. ✅ `number` présent et formaté `{siteCode}-TP-{NNNN}` (ici `86-TP-0001`). +> 4. ✅ `netWeight` cohérent = `full - empty` = `14300 - 7150` = **`7150`** (RG-5.05). +> +> **Note `skip_null_values`** : les champs null sont **omis** du JSON (ex. `supplier`, `otherLabel`, `emptyManualNumber`, `fullManualNumber` absents quand null). Le front ne doit pas présumer leur présence — lire avec un défaut (`?? null`). -**`GET /api/weighing_tickets` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3) : +**`GET /api/weighing_tickets?search=86-TP-0001` (LISTE)** — enveloppe Hydra AP4 (`member`/`totalItems`/`view`), filtrée site courant (§ 2.3). Capture réelle : ```jsonc { @@ -524,41 +528,94 @@ class WeighingTicket implements TimestampableInterface, BlamableInterface "totalItems": 1, "member": [ { - "@id": "/api/weighing_tickets/1", + "@id": "/api/weighing_tickets/9", "@type": "WeighingTicket", - "id": 1, + "id": 9, "number": "86-TP-0001", "counterpartyType": "CLIENT", - "client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" }, - "supplier": null, - "otherLabel": null, - "displayDate": "2026-06-17T09:12:00+02:00", - "netWeight": 12340, + "client": { + "@id": "/api/clients/629", + "@type": "Client", + "id": 629, + "companyName": "NÉGOCE MÉTAUX ATLANTIQUE", + "triageService": false, + "categories": [], + "createdAt": "2026-06-18T11:50:47+02:00", + "updatedAt": "2026-06-18T11:50:47+02:00", + "createdBy": "/api/me", + "updatedBy": "/api/me", + "sites": [], + "isArchived": false + }, "plateFreeFormat": false, - "createdAt": "2026-06-17T09:12:00+02:00", - "updatedAt": "2026-06-17T09:12:00+02:00" + "netWeight": 7150, + "createdAt": "2026-06-18T11:50:48+02:00", + "updatedAt": "2026-06-18T11:50:48+02:00", + "createdBy": "/api/me", + "updatedBy": "/api/me", + "displayDate": "2026-06-17T09:12:00+02:00" + // supplier / otherLabel omis (null → skip_null_values) } ], - "view": { "@id": "/api/weighing_tickets", "@type": "PartialCollectionView" } + "view": { "@id": "/api/weighing_tickets?search=86-TP-0001", "@type": "PartialCollectionView" } } ``` -**`GET /api/weighing_tickets/{id}` (DÉTAIL)** — ajoute les pesées : +**`GET /api/weighing_tickets/9` (DÉTAIL)** — ajoute le site embarqué (avec `code`), l'immatriculation et les deux pesées. Capture réelle : ```jsonc { - "@id": "/api/weighing_tickets/1", + "@context": "/api/contexts/WeighingTicket", + "@id": "/api/weighing_tickets/9", "@type": "WeighingTicket", - "id": 1, + "id": 9, "number": "86-TP-0001", - "site": { "@id": "/api/sites/1", "@type": "Site", "id": 1, "name": "Châtellerault", "code": "86" }, + "site": { + "@id": "/api/sites/1", + "@type": "Site", + "id": 1, + "name": "Chatellerault", + "code": "86", + "street": "14 All. d'Argenson", + "postalCode": "86100", + "city": "Châtellerault", + "color": "#056CF2", + "createdAt": "2026-06-17T17:07:47+02:00", + "updatedAt": "2026-06-17T17:07:47+02:00", + "fullAddress": "14 All. d'Argenson\n86100 Châtellerault" + }, "counterpartyType": "CLIENT", - "client": { "@id": "/api/clients/117", "@type": "Client", "id": 117, "companyName": "NÉGOCE MÉTAUX ATLANTIQUE" }, + "client": { + "@id": "/api/clients/629", + "@type": "Client", + "id": 629, + "companyName": "NÉGOCE MÉTAUX ATLANTIQUE", + "triageService": false, + "categories": [], + "createdAt": "2026-06-18T11:50:47+02:00", + "updatedAt": "2026-06-18T11:50:47+02:00", + "createdBy": "/api/me", + "updatedBy": "/api/me", + "sites": [], + "isArchived": false + }, "immatriculation": "AB-123-CD", "plateFreeFormat": false, - "emptyDate": "2026-06-17T09:00:00+02:00", "emptyWeight": 14660, "emptyDsd": 41, "emptyMode": "AUTO", "emptyManualNumber": null, - "fullDate": "2026-06-17T09:12:00+02:00", "fullWeight": 27000, "fullDsd": 42, "fullMode": "AUTO", "fullManualNumber": null, - "netWeight": 12340 + "emptyDate": "2026-06-17T09:00:00+02:00", + "emptyWeight": 7150, + "emptyDsd": 1, + "emptyMode": "AUTO", + "fullDate": "2026-06-17T09:12:00+02:00", + "fullWeight": 14300, + "fullDsd": 2, + "fullMode": "AUTO", + "netWeight": 7150, + "createdAt": "2026-06-18T11:50:48+02:00", + "updatedAt": "2026-06-18T11:50:48+02:00", + "createdBy": "/api/me", + "updatedBy": "/api/me", + "displayDate": "2026-06-17T09:12:00+02:00" + // emptyManualNumber / fullManualNumber omis (null → skip_null_values) } ``` @@ -609,6 +666,12 @@ Action **autonome** (le ticket n'est pas encore créé quand on déclenche la pe - Colonnes : Numéro, Contrepartie (Client/Fournisseur/Autre + nom), Date, Immatriculation, Poids vide, Poids plein, **Poids net**, DSD vide/plein. - Génération via le helper XLSX standard projet (skill `xlsx`). Endpoint : provider dédié renvoyant un binaire (`Content-Type` xlsx) — whitelisté pagination (`EXCLUDED`) car export complet. +### 4.6 Impression — `GET /api/weighing_tickets/{id}/print.pdf` (bon de pesée, OWNER Tristan) + +- Opération API Platform dédiée (provider renvoyant un binaire PDF, **pas de controller**). Sécurité `is_granted('logistique.weighing_tickets.view')`. +- Rendu d'un **template Twig** (`templates/logistique/weighing_ticket_print.html.twig`) → PDF (cf. § 2.12). `Content-Type: application/pdf`, inline. +- Contenu : cf. § 2.12. Données déjà portées par le ticket — aucun champ API supplémentaire. + ## 5. RBAC, module & sidebar ### 5.1 `LogistiqueModule::permissions()` @@ -681,7 +744,7 @@ final class WeighingTicketFieldNormalizer | **RG-5.05** | back | Poids net = `poids plein − poids vide`, calculé serveur, exposé en liste/détail (§ 2.8 — confirmé Matthieu 17/06). | | **RG-5.06** | docx+back | Pesée bascule indisponible → erreur explicite + bascule en pesée manuelle. Au M5, le pont est un **stub** (poids aléatoire ∈ [10000,50000] kg, § 2.6). | | **RG-5.07** | docx | Formulaire à vide : `Date` = date du jour par défaut ; `Poids` et `DSD` **readonly** (remplis par la pesée, pas saisis). | -| **RG-5.08** | docx | « Valider » (création) → enregistre + ouvre la modal d'impression. En modification : bouton « Valider » → « Enregistrer », bouton d'impression disponible (absent à l'ajout). Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Le bon d'impression est réalisé par Tristan** (§ 2.12). | +| **RG-5.08** | docx | « Valider » (création) → enregistre + ouvre le **bon de pesée (PDF servi par le back)**. En modification : bouton « Valider » → « Enregistrer », bouton « Imprimer » disponible (absent à l'ajout) → ouvre le même PDF. Le bouton « Enregistré » du bloc pesée à vide disparaît en modification. **Bon de pesée = PDF généré back via template Twig, OWNER Tristan** (§ 2.12 / § 4.6). | | **RG-5.09** | back | Site & numéro immuables après création ; liste cloisonnée par site courant (§ 2.3, à confirmer). | | **RG-5.10** | back | Normalisation immatriculation (trim/UPPER/format) côté serveur (§ 6). | @@ -706,7 +769,7 @@ Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via `#[Assert\Callback] | HP-M5-01 | Vue multi-sites des tickets (retirer le cloisonnement + filtre `?siteId=`) si demandé (§ 2.3). | | HP-M5-02 | Driver matériel réel du pont bascule (protocole série/TCP, parsing trame, reconnexion) derrière `WeighbridgeReaderInterface` (§ 2.6). | | HP-M5-03 | Sens réception-expédition explicite + contrôle de signe du net (le net reste `plein − vide`, § 2.8). | -| HP-M5-04 | Génération PDF serveur du ticket (`/print.pdf`) si l'impression navigateur ne suffit pas (§ 2.12). | +| ~~HP-M5-04~~ | **Passé en périmètre** : bon de pesée = PDF serveur via template Twig → ticket back dédié (OWNER Tristan, § 2.12 / § 4.6). | | HP-M5-05 | Archivage fonctionnel des tickets (non prévu au docx — § 2.13). | ## 10. Tickets Lesstime (à découper — back en tête) @@ -720,8 +783,8 @@ Cohérence inter-champs (RG-5.03, RG-5.01) implémentée via `#[Assert\Callback] | 4 | `WeighingTicketProvider` + `WeighingTicketProcessor` (numérotation, RG-5.03/5.05, normalisation) | Backend | | 5 | Export XLSX | Backend | | 6 | Tests PHPUnit RG-5.01→5.10 + capture contrat JSON | Backend | +| 6.bis (ERP-192) | **Bon de pesée — PDF via template Twig** (`/print.pdf`, § 2.12 / § 4.6) | **Backend (OWNER Tristan)** | | 7 | Page liste `/weighing-tickets` (usePaginatedList) + export | Frontend | -| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) | Frontend | -| 9 | Écran Modification (la **modal/bon d'impression** = **Tristan**, § 2.12) | Frontend | +| 8 | Écran Ajouter (formulaires vide + plein, pesée bascule/manuelle, masque immat) + ouverture PDF à la validation | Frontend | +| 9 | Écran Modification + bouton « Imprimer » (ouvre le PDF back) | Frontend | | 10 | i18n + libellé audit + branchement site courant | Frontend | -| — | **Bon d'impression du ticket de pesée** | **Tristan (hors découpe M5)** | diff --git a/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php b/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php new file mode 100644 index 0000000..e16b1c6 --- /dev/null +++ b/tests/Module/Logistique/Api/AbstractWeighingTicketApiTestCase.php @@ -0,0 +1,192 @@ +getEm(); + + // Tickets referencant un Client de test d'abord (FK client_id RESTRICT) : + // purge DBAL brute pour liberer les Client avant de les supprimer. + $em->getConnection()->executeStatement( + 'DELETE FROM weighing_ticket WHERE client_id IN (SELECT id FROM client WHERE company_name LIKE :p)', + ['p' => self::TEST_CLIENT_PREFIX.'%'], + ); + $em->createQuery('DELETE FROM '.ClientEntity::class.' c WHERE c.companyName LIKE :p') + ->setParameter('p', self::TEST_CLIENT_PREFIX.'%')->execute() + ; + $em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :p') + ->setParameter('p', 'testuser_%')->execute() + ; + $em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :p') + ->setParameter('p', 'test_%')->execute() + ; + + parent::tearDown(); + } + + /** + * Garde-fou ERP-101 (miroir M4) : une 422 doit porter une violation sur le + * `propertyPath` attendu, et pas seulement le bon code HTTP. + */ + protected static function assertViolationOnPath(object $response, string $path): void + { + /** @var ResponseInterface $response */ + $paths = array_column($response->toArray(false)['violations'] ?? [], 'propertyPath'); + + self::assertContains( + $path, + $paths, + sprintf('Aucune violation sur "%s" (paths: %s).', $path, implode(', ', $paths)), + ); + } + + protected function firstSite(): Site + { + $site = $this->getEm()->getRepository(Site::class)->findAll()[0] ?? null; + self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).'); + + return $site; + } + + protected function siteByCode(string $code): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]); + self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code)); + + return $site; + } + + /** + * Cree un user non-admin portant view + manage, lui positionne $site comme site + * courant (cloisonnement + numerotation) et renvoie un client authentifie. + */ + protected function authManageOnSite(Site $site): Client + { + $creds = $this->createUserWithPermissions([ + 'logistique.weighing_tickets.view', + 'logistique.weighing_tickets.manage', + ]); + + $this->setCurrentSite($creds['username'], $site); + + return $this->authenticatedClient($creds['username'], $creds['password']); + } + + /** + * Positionne le site courant d'un user (par username) — persiste en base, donc + * survit au reboot du kernel a l'authentification. + */ + protected function setCurrentSite(string $username, Site $site): void + { + $em = $this->getEm(); + $user = $em->getRepository(User::class)->findOneBy(['username' => $username]); + self::assertInstanceOf(User::class, $user); + + $user->setCurrentSite($em->getReference(Site::class, $site->getId())); + $em->flush(); + } + + /** + * Seede un Client minimal (companyName prefixe pour la purge). Sert de + * contrepartie aux tickets de test. + */ + protected function seedTestClient(string $label): ClientEntity + { + $em = $this->getEm(); + $suffix = substr(bin2hex(random_bytes(3)), 0, 6); + + $client = new ClientEntity(); + $client->setCompanyName(mb_strtoupper(self::TEST_CLIENT_PREFIX.' '.$label.' '.$suffix, 'UTF-8')); + $em->persist($client); + $em->flush(); + + return $client; + } + + protected function clientIri(ClientEntity $client): string + { + return '/api/clients/'.$client->getId(); + } + + /** + * Payload POST de reference : contrepartie Client, pesee a vide + a plein en + * mode AUTO (le Processor (re)alloue les DSD et calcule le net = 14300 - 7150). + * + * @return array + */ + protected function validClientTicketPayload(ClientEntity $client): array + { + return [ + 'counterpartyType' => 'CLIENT', + 'client' => $this->clientIri($client), + 'immatriculation' => 'AB-123-CD', + 'plateFreeFormat' => false, + 'emptyDate' => '2026-06-17T09:00:00+02:00', + 'emptyWeight' => 7150, + 'emptyMode' => 'AUTO', + 'fullDate' => '2026-06-17T09:12:00+02:00', + 'fullWeight' => 14300, + 'fullMode' => 'AUTO', + ]; + } + + /** + * POST un ticket et renvoie la reponse (assertions de statut a la charge de + * l'appelant). + */ + protected function postTicket(Client $http, array $payload): ResponseInterface + { + return $http->request('POST', '/api/weighing_tickets', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ]); + } + + /** + * Retrouve un membre d'une collection Hydra par son id. + * + * @param array $collection + * + * @return array|null + */ + protected function memberById(array $collection, int $id): ?array + { + foreach ($collection['member'] ?? [] as $member) { + if (($member['id'] ?? null) === $id) { + return $member; + } + } + + return null; + } +} diff --git a/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php b/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php new file mode 100644 index 0000000..32e3bfd --- /dev/null +++ b/tests/Module/Logistique/Api/WeighingTicketNumberingTest.php @@ -0,0 +1,92 @@ +siteByCode('86'); + $http = $this->authManageOnSite($site); + $client = $this->seedTestClient('Num'); + + $first = $this->postTicket($http, $this->validClientTicketPayload($client)); + self::assertResponseStatusCodeSame(201); + $second = $this->postTicket($http, $this->validClientTicketPayload($client)); + self::assertResponseStatusCodeSame(201); + + $n1 = (string) $first->toArray()['number']; + $n2 = (string) $second->toArray()['number']; + + self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n1); + self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $n2); + self::assertNotSame($n1, $n2, 'Deux tickets du meme site portent des numeros distincts (unicite).'); + + // Sequence : le second numero = premier + 1 (compteur par site). + self::assertSame($this->suffix($n1) + 1, $this->suffix($n2)); + } + + public function testNumberingIsIsolatedPerSite(): void + { + $client = $this->seedTestClient('IsoSite'); + + $http86 = $this->authManageOnSite($this->siteByCode('86')); + $http17 = $this->authManageOnSite($this->siteByCode('17')); + + $n86 = (string) $this->postTicket($http86, $this->validClientTicketPayload($client))->toArray()['number']; + $n17 = (string) $this->postTicket($http17, $this->validClientTicketPayload($client))->toArray()['number']; + + // Chaque site encode son propre code dans le numero ; sequences disjointes. + self::assertStringStartsWith('86-TP-', $n86); + self::assertStringStartsWith('17-TP-', $n17); + } + + public function testNumberAndSiteAreImmutableOnPatch(): void + { + $site = $this->siteByCode('86'); + $http = $this->authManageOnSite($site); + $client = $this->seedTestClient('Immutable'); + + $created = $this->postTicket($http, $this->validClientTicketPayload($client))->toArray(); + $id = (int) $created['id']; + $number = (string) $created['number']; + + // Tentative de re-ecriture du numero et du site (aucun groupe d'ecriture) + + // changement legitime de la pesee a plein -> net recalcule. + $patched = $http->request('PATCH', '/api/weighing_tickets/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => [ + 'number' => 'HACK-TP-9999', + 'site' => '/api/sites/'.$this->siteByCode('17')->getId(), + 'fullWeight' => 20000, + ], + ])->toArray(); + + self::assertSame($number, $patched['number'], 'Le numero est immuable (RG-5.02 / RG-5.09).'); + self::assertSame('86', $patched['site']['code'], 'Le site est immuable (RG-5.09).'); + // Net recalcule : 20000 - 7150 = 12850 (RG-5.05). + self::assertSame(12850, $patched['netWeight']); + } + + /** Suffixe numerique {NNNN} d'un numero {siteCode}-TP-{NNNN}. */ + private function suffix(string $number): int + { + return (int) substr($number, strrpos($number, '-') + 1); + } +} diff --git a/tests/Module/Logistique/Api/WeighingTicketRBACMatrixTest.php b/tests/Module/Logistique/Api/WeighingTicketRBACMatrixTest.php new file mode 100644 index 0000000..b4426f7 --- /dev/null +++ b/tests/Module/Logistique/Api/WeighingTicketRBACMatrixTest.php @@ -0,0 +1,121 @@ + 201) suppose un site courant (numerotation + cloisonnement, + * § 2.3) : on le positionne pour chaque role autorise a ecrire. + * + * @internal + */ +final class WeighingTicketRBACMatrixTest extends AbstractWeighingTicketApiTestCase +{ + private const string PWD = RbacDemoFixtures::DEMO_PASSWORD; + + protected function setUp(): void + { + parent::setUp(); + + // Seed idempotent des roles metier + matrice § 5.2 + comptes demo (meme + // chemin qu'en recette). + 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 logistique.weighing_tickets.* sont-elles synchronisees (app:sync-permissions) ?', + ); + + self::ensureKernelShutdown(); + } + + public function testAdminCanViewAndManage(): void + { + $this->assertCanViewAndManage('admin', 'admin'); + } + + public function testBureauCanViewAndManage(): void + { + $this->assertCanViewAndManage('bureau', self::PWD); + } + + public function testUsineCanViewAndManage(): void + { + $this->assertCanViewAndManage('usine', self::PWD); + } + + public function testComptaHasNoAccess(): void + { + $this->assertHasNoAccess('compta'); + } + + public function testCommercialeHasNoAccess(): void + { + $this->assertHasNoAccess('commerciale'); + } + + public function testAnonymousIsUnauthorized(): void + { + $client = self::createClient(); + $client->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(401); + } + + /** + * Role autorise : GET 200 (view) + POST 201 (manage). Le site courant est + * positionne avant le POST pour permettre la numerotation. + */ + private function assertCanViewAndManage(string $username, string $password): void + { + $site = $this->firstSite(); + $this->setCurrentSite($username, $site); + + $clientEntity = $this->seedTestClient('Rbac '.$username); + $http = $this->authenticatedClient($username, $password); + + $http->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $this->postTicket($http, $this->validClientTicketPayload($clientEntity)); + self::assertResponseStatusCodeSame(201); + } + + /** + * Role sans acces : 403 en lecture (view absent) ET en ecriture (manage absent). + */ + private function assertHasNoAccess(string $username): void + { + $clientEntity = $this->seedTestClient('Rbac '.$username); + $http = $this->authenticatedClient($username, self::PWD); + + $http->request('GET', '/api/weighing_tickets', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403); + + $this->postTicket($http, $this->validClientTicketPayload($clientEntity)); + self::assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php b/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php new file mode 100644 index 0000000..7ae6ebe --- /dev/null +++ b/tests/Module/Logistique/Api/WeighingTicketSerializationContractTest.php @@ -0,0 +1,101 @@ +siteByCode('86'); + $http = $this->authManageOnSite($site); + $clientEntity = $this->seedTestClient('Negoce'); + + $created = $this->postTicket($http, $this->validClientTicketPayload($clientEntity)); + self::assertResponseStatusCodeSame(201); + $createdBody = $created->toArray(); + + $id = (int) $createdBody['id']; + $number = (string) $createdBody['number']; + + $detail = $http->request('GET', '/api/weighing_tickets/'.$id, ['headers' => ['Accept' => self::LD]])->toArray(); + $list = $http->request('GET', '/api/weighing_tickets?search='.$number, ['headers' => ['Accept' => self::LD]])->toArray(); + + // Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:). + self::assertArrayHasKey('member', $list); + self::assertArrayNotHasKey('hydra:member', $list); + + $row = $this->memberById($list, $id); + self::assertNotNull($row, 'Le ticket cree doit apparaitre dans la liste filtree.'); + + // === Piege #1 : relations embarquees en OBJET (pas IRI nu) === + self::assertIsArray($row['client'], 'client doit etre un objet embarque (client:read), pas un IRI nu.'); + self::assertArrayHasKey('companyName', $row['client']); + // supplier null sur une contrepartie Client (cle potentiellement omise par + // skip_null_values — tolerant aux deux cas, jamais un IRI nu). + self::assertNull($row['supplier'] ?? null); + + // === Piege #2 : booleen plateFreeFormat present === + self::assertArrayHasKey('plateFreeFormat', $row); + self::assertFalse($row['plateFreeFormat']); + + // === Piege #3 : number formate {siteCode}-TP-{NNNN} === + self::assertArrayHasKey('number', $row); + self::assertMatchesRegularExpression('/^86-TP-\d{4}$/', $row['number']); + + // === Piege #4 : netWeight = full - empty (14300 - 7150) === + self::assertSame(7150, $row['netWeight']); + + // displayDate (date du ticket = fullDate ?? emptyDate) expose en liste. + self::assertArrayHasKey('displayDate', $row); + + // === DETAIL : site embarque (avec code), immatriculation, les 2 pesees === + self::assertIsArray($detail['site']); + self::assertSame('86', $detail['site']['code']); + self::assertSame('AB-123-CD', $detail['immatriculation']); + self::assertSame(7150, $detail['emptyWeight']); + self::assertSame(14300, $detail['fullWeight']); + self::assertSame(7150, $detail['netWeight']); + self::assertIsArray($detail['client']); + self::assertArrayHasKey('companyName', $detail['client']); + + $this->dumpDodIfRequested($list, $detail); + } + + /** + * DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si WEIGHING_TICKET_DOD_DUMP + * est positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis. + * + * @param array $list + * @param array $detail + */ + private function dumpDodIfRequested(array $list, array $detail): void + { + if (false === getenv('WEIGHING_TICKET_DOD_DUMP')) { + return; + } + + $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + file_put_contents('/tmp/weighing-ticket-dod-list.json', json_encode($list, $flags)); + file_put_contents('/tmp/weighing-ticket-dod-detail.json', json_encode($detail, $flags)); + } +} diff --git a/tests/Module/Logistique/Application/Service/ImmatriculationNormalizationTest.php b/tests/Module/Logistique/Application/Service/ImmatriculationNormalizationTest.php new file mode 100644 index 0000000..0848ae2 --- /dev/null +++ b/tests/Module/Logistique/Application/Service/ImmatriculationNormalizationTest.php @@ -0,0 +1,162 @@ +normalizer = new WeighingTicketFieldNormalizer(); + } + + // === Volet 1 : normalisation pure (masque + « Tout format ») === + + #[DataProvider('provideMaskedPlates')] + public function testMaskedPlateIsReformattedToCanonicalSiv(string $input): void + { + self::assertSame('AB-123-CD', $this->normalizer->normalizeImmatriculation($input, false)); + } + + /** + * @return iterable + */ + public static function provideMaskedPlates(): iterable + { + yield 'deja canonique' => ['AB-123-CD']; + yield 'minuscules nues' => ['ab123cd']; + yield 'espaces' => ['AB 123 CD']; + yield 'minuscules tirets'=> ['ab-123-cd']; + yield 'espaces de garde' => [' ab-123-cd ']; + } + + public function testInvalidPlateWithoutFreeFormatThrows(): void + { + $this->expectException(InvalidImmatriculationException::class); + $this->normalizer->normalizeImmatriculation('ABC-12-D', false); + } + + public function testFreeFormatBypassesTheMask(): void + { + // Ancienne plaque / engin : aucune contrainte de masque, juste trim + UPPER. + self::assertSame('1234 WW 75', $this->normalizer->normalizeImmatriculation(' 1234 ww 75 ', true)); + self::assertSame('ENGIN-XYZ', $this->normalizer->normalizeImmatriculation('engin-xyz', true)); + } + + public function testNullAndBlankAreNormalizedToNull(): void + { + self::assertNull($this->normalizer->normalizeImmatriculation(null, false)); + self::assertNull($this->normalizer->normalizeImmatriculation(' ', false)); + self::assertNull($this->normalizer->normalizeImmatriculation(' ', true)); + } + + public function testOtherLabelIsTrimmedAndBlankBecomesNull(): void + { + self::assertSame('Reprise interne', $this->normalizer->normalizeOtherLabel(' Reprise interne ')); + self::assertNull($this->normalizer->normalizeOtherLabel(' ')); + self::assertNull($this->normalizer->normalizeOtherLabel(null)); + } + + // === Volet 2 : mapping 422 par le Processor (RG-5.01, ERP-101) === + + public function testProcessorMapsInvalidPlateTo422OnImmatriculationPath(): void + { + $ticket = (new WeighingTicket()) + ->setCounterpartyType('AUTRE') + ->setOtherLabel('Reprise') + ->setImmatriculation('PLAQUE INVALIDE') + ->setPlateFreeFormat(false) + ; + + try { + $this->makeProcessor()->process($ticket, new Post()); + self::fail('Une ValidationException (422) etait attendue sur une immatriculation invalide.'); + } catch (ValidationException $e) { + $paths = []; + foreach ($e->getConstraintViolationList() as $violation) { + $paths[] = $violation->getPropertyPath(); + } + self::assertContains('immatriculation', $paths); + } + } + + public function testProcessorReformatsValidPlateAndHonorsFreeFormat(): void + { + // Masque applique a la persistance (saisie nue -> canonique). + $masked = (new WeighingTicket()) + ->setCounterpartyType('AUTRE') + ->setOtherLabel('Reprise') + ->setImmatriculation('ab123cd') + ->setPlateFreeFormat(false) + ; + $this->makeProcessor()->process($masked, new Post()); + self::assertSame('AB-123-CD', $masked->getImmatriculation()); + + // « Tout format » : la plaque libre passe (UPPER seulement), aucune 422. + $free = (new WeighingTicket()) + ->setCounterpartyType('AUTRE') + ->setOtherLabel('Reprise') + ->setImmatriculation('vieux 4321 zz') + ->setPlateFreeFormat(true) + ; + $this->makeProcessor()->process($free, new Post()); + self::assertSame('VIEUX 4321 ZZ', $free->getImmatriculation()); + } + + private function makeProcessor(): WeighingTicketProcessor + { + $persist = $this->createStub(ProcessorInterface::class); + $persist->method('process')->willReturnArgument(0); + + $siteProvider = $this->createStub(CurrentSiteProviderInterface::class); + $siteProvider->method('get')->willReturn( + (new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233'))->setCode('86'), + ); + + $numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class); + $numberAllocator->method('allocate')->willReturn('86-TP-0001'); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('contains')->willReturn(false); + + return new WeighingTicketProcessor( + $persist, + $siteProvider, + $numberAllocator, + $this->createStub(DsdAllocatorInterface::class), + new WeighingTicketFieldNormalizer(), + $em, + ); + } +} diff --git a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php new file mode 100644 index 0000000..ee972f7 --- /dev/null +++ b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/CounterpartyValidationTest.php @@ -0,0 +1,187 @@ +validator = Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator() + ; + } + + // === Volet 1 : presence du champ requis (Assert\Callback) === + + public function testClientBranchRequiresClient(): void + { + $ticket = $this->baseTicket('CLIENT'); + + // Sans client : violation attendue sur le path « client ». + self::assertContains('client', $this->violationPaths($ticket)); + + // Avec client : plus de violation sur « client ». + $ticket->setClient(new Client()); + self::assertNotContains('client', $this->violationPaths($ticket)); + } + + public function testSupplierBranchRequiresSupplier(): void + { + $ticket = $this->baseTicket('FOURNISSEUR'); + + self::assertContains('supplier', $this->violationPaths($ticket)); + + $ticket->setSupplier(new Supplier()); + self::assertNotContains('supplier', $this->violationPaths($ticket)); + } + + public function testOtherBranchRequiresOtherLabel(): void + { + $ticket = $this->baseTicket('AUTRE'); + + // Ni null ni chaine vide apres trim ne suffisent (RG-5.03). + self::assertContains('otherLabel', $this->violationPaths($ticket)); + + $ticket->setOtherLabel(' '); + self::assertContains('otherLabel', $this->violationPaths($ticket)); + + $ticket->setOtherLabel('Reprise interne'); + self::assertNotContains('otherLabel', $this->violationPaths($ticket)); + } + + // === Volet 2 : exclusivite (le Processor null-ifie les champs hors-branche) === + + public function testClientBranchNullifiesSupplierAndOtherLabel(): void + { + $ticket = $this->baseTicket('CLIENT') + ->setClient(new Client()) + ->setSupplier(new Supplier()) + ->setOtherLabel('parasite') + ; + + $this->makeProcessor()->process($ticket, new Post()); + + self::assertInstanceOf(Client::class, $ticket->getClient()); + self::assertNull($ticket->getSupplier()); + self::assertNull($ticket->getOtherLabel()); + } + + public function testSupplierBranchNullifiesClientAndOtherLabel(): void + { + $ticket = $this->baseTicket('FOURNISSEUR') + ->setClient(new Client()) + ->setSupplier(new Supplier()) + ->setOtherLabel('parasite') + ; + + $this->makeProcessor()->process($ticket, new Post()); + + self::assertInstanceOf(Supplier::class, $ticket->getSupplier()); + self::assertNull($ticket->getClient()); + self::assertNull($ticket->getOtherLabel()); + } + + public function testOtherBranchNullifiesClientAndSupplierAndTrimsLabel(): void + { + $ticket = $this->baseTicket('AUTRE') + ->setClient(new Client()) + ->setSupplier(new Supplier()) + ->setOtherLabel(' Reprise interne ') + ; + + $this->makeProcessor()->process($ticket, new Post()); + + self::assertNull($ticket->getClient()); + self::assertNull($ticket->getSupplier()); + self::assertSame('Reprise interne', $ticket->getOtherLabel()); + } + + /** + * Ticket minimal VALIDE hors contrepartie : counterpartyType + immatriculation + * renseignes, afin d'isoler la violation de contrepartie (et pas un NotBlank + * collateral) dans le volet 1. + */ + private function baseTicket(string $type): WeighingTicket + { + return (new WeighingTicket()) + ->setCounterpartyType($type) + ->setImmatriculation('AB-123-CD') + ; + } + + /** + * Liste des propertyPath des violations de l'entite. + * + * @return list + */ + private function violationPaths(WeighingTicket $ticket): array + { + $paths = []; + foreach ($this->validator->validate($ticket) as $violation) { + $paths[] = $violation->getPropertyPath(); + } + + return $paths; + } + + private function makeProcessor(): WeighingTicketProcessor + { + $persist = $this->createStub(ProcessorInterface::class); + $persist->method('process')->willReturnArgument(0); + + $siteProvider = $this->createStub(CurrentSiteProviderInterface::class); + $siteProvider->method('get')->willReturn( + (new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233'))->setCode('86'), + ); + + $numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class); + $numberAllocator->method('allocate')->willReturn('86-TP-0001'); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('contains')->willReturn(false); + + return new WeighingTicketProcessor( + $persist, + $siteProvider, + $numberAllocator, + $this->createStub(DsdAllocatorInterface::class), + new WeighingTicketFieldNormalizer(), + $em, + ); + } +} diff --git a/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/NetWeightTest.php b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/NetWeightTest.php new file mode 100644 index 0000000..10279f6 --- /dev/null +++ b/tests/Module/Logistique/Infrastructure/ApiPlatform/State/Processor/NetWeightTest.php @@ -0,0 +1,130 @@ +setEmptyWeight(7150) + ->setFullWeight(14300) + ; + + $this->makeProcessor(isNew: true)->process($ticket, new Post()); + + // 14300 - 7150 = 7150 (exemple maquette § 2.8). + self::assertSame(7150, $ticket->getNetWeight()); + } + + public function testNetIsNullWhenFullWeightMissing(): void + { + $ticket = (new WeighingTicket())->setEmptyWeight(7150); + + $this->makeProcessor(isNew: true)->process($ticket, new Post()); + + self::assertNull($ticket->getNetWeight()); + } + + public function testNetIsNullWhenEmptyWeightMissing(): void + { + $ticket = (new WeighingTicket())->setFullWeight(14300); + + $this->makeProcessor(isNew: true)->process($ticket, new Post()); + + self::assertNull($ticket->getNetWeight()); + } + + public function testNetIsNullWhenNoWeighing(): void + { + $ticket = new WeighingTicket(); + + $this->makeProcessor(isNew: true)->process($ticket, new Post()); + + self::assertNull($ticket->getNetWeight()); + } + + /** + * RG-5.05 : a la modification (PATCH = entite deja geree par l'ORM), le net est + * recalcule a partir des poids courants — ici la pesee a plein renseignee apres + * coup complete le ticket. + */ + public function testNetIsRecomputedOnPatch(): void + { + $ticket = (new WeighingTicket()) + ->setSite($this->site()) + ->setEmptyWeight(7150) + ->setFullWeight(20000) + ; + + $this->makeProcessor(isNew: false)->process($ticket, new Patch()); + + self::assertSame(12850, $ticket->getNetWeight()); + } + + /** + * Construit le Processor avec des dependances stubbees. `isNew` pilote + * EntityManager::contains() : false => creation (POST, attribution site/numero), + * true => entite geree (PATCH, ni site ni numero retouches). + */ + private function makeProcessor(bool $isNew): WeighingTicketProcessor + { + $persist = $this->createStub(ProcessorInterface::class); + $persist->method('process')->willReturnArgument(0); + + $siteProvider = $this->createStub(CurrentSiteProviderInterface::class); + $siteProvider->method('get')->willReturn($this->site()); + + $numberAllocator = $this->createStub(WeighingTicketNumberAllocatorInterface::class); + $numberAllocator->method('allocate')->willReturn('86-TP-0001'); + + $dsdAllocator = $this->createStub(DsdAllocatorInterface::class); + $dsdAllocator->method('next')->willReturn(99); + + $em = $this->createStub(EntityManagerInterface::class); + $em->method('contains')->willReturn(!$isNew); + + return new WeighingTicketProcessor( + $persist, + $siteProvider, + $numberAllocator, + $dsdAllocator, + new WeighingTicketFieldNormalizer(), + $em, + ); + } + + private function site(): Site + { + // getId() reste null : numberAllocator et dsdAllocator sont stubbes, donc + // aucune requete reelle ne depend de l'id du site. + return (new Site('Châtellerault', 'Rue du Pont', null, '86000', 'Châtellerault', '#112233')) + ->setCode('86') + ; + } +}