From 9202d2ee6fb53a5c56236944e4cd0d3e4af7ee1a Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 4 Jun 2026 10:11:33 +0200 Subject: [PATCH] feat(commercial) : messages de validation FR sur les contraintes back + garde-fou (ERP-107) Audit et complete les messages des contraintes #[Assert\*] des entites metier (pendant back du mapping d'erreur par champ front ERP-101) : - Message FR explicite sur toutes les contraintes (Email, NotBlank, Length, Bic, Iban, PositiveOrZero, Count...) des entites Client, ClientContact, ClientAddress, ClientRib, Category, Role, User. - Ajout des Assert\Length manquantes calees sur le length de la colonne ORM (telephones VARCHAR(20), siren, nTva, accountNumber, username...) : evite une erreur Postgres 500 non rattachee au champ au profit d'une 422 propre. - Locale FR globale (symfony/translation + default_locale: fr) comme filet pour les messages natifs non surcharges. - Garde-fou tests/Architecture/EntityConstraintsHaveFrenchMessageTest : echoue si une contrainte n'a pas de message FR explicite ou si Assert\Length.max diverge du length ORM (whitelist justifiee pour les formats Bic/Iban/Regex). - Test fonctionnel du JSON 422 reel (message FR + propertyPath consommable par useFormErrors cote front). - Convention documentee dans .claude/rules/backend.md. Tests : 469 verts (1793 assertions). --- .claude/rules/backend.md | 36 ++ .claude/rules/frontend.md | 12 + composer.json | 1 + composer.lock | 95 ++++- config/packages/translation.yaml | 12 + src/Module/Catalog/Domain/Entity/Category.php | 2 +- .../Commercial/Domain/Entity/Client.php | 22 +- .../Domain/Entity/ClientAddress.php | 14 +- .../Domain/Entity/ClientContact.php | 14 +- .../Commercial/Domain/Entity/ClientRib.php | 14 +- src/Module/Core/Domain/Entity/Role.php | 8 +- src/Module/Core/Domain/Entity/User.php | 3 + symfony.lock | 13 + ...EntityConstraintsHaveFrenchMessageTest.php | 363 ++++++++++++++++++ tests/Module/Commercial/Api/ClientApiTest.php | 32 ++ translations/.gitignore | 2 + 16 files changed, 617 insertions(+), 26 deletions(-) create mode 100644 config/packages/translation.yaml create mode 100644 tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php create mode 100644 translations/.gitignore diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 758731f..4a3d3d6 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -6,6 +6,42 @@ - PHP CS Fixer : regles Symfony + PSR-12 + strict types (commande : `make php-cs-fixer-allow-risky`) - Commentaires (docblock, inline, bloc) **en francais** ; code (classes, methodes, variables) en anglais +## Messages de validation (obligatoire) + +**Toute contrainte `#[Assert\*]` portee par une entite metier doit avoir un message FR explicite**, et **`Assert\Length.max` doit refleter le `length` de la colonne ORM**. Pendant logique back de la regle de mapping d'erreur par champ cote front (ERP-101 : `useFormErrors` / `mapViolationsToRecord` affiche sous chaque champ le `message` renvoye par le back). + +Pourquoi : +- Sans `message:` explicite, Symfony renvoie le defaut **anglais** (« This value is not a valid email address. »). La locale FR globale (`default_locale: fr` dans `framework.yaml`) sert de FILET via `validators.fr.xlf`, mais les contraintes metier portent en plus leur message FR pour un controle total. +- Une colonne string bornee **sans `Assert\Length`** echoue au niveau Postgres (500 generique, non rattachee au champ) au lieu d'une 422 propre. Le `max` doit egaler le `length` ORM (anti-derive). + +Pattern par champ scalaire : + +```php +// Email metier +#[Assert\Email(message: 'L\'adresse email n\'est pas valide.')] + +// Longueur calee sur la colonne (VARCHAR(120)) +#[ORM\Column(length: 120)] +#[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] + +// Obligatoire (aligner nullable DB / NotBlank back / required front) +#[Assert\NotBlank(message: 'Le téléphone est obligatoire.', normalizer: 'trim')] +``` + +Coherence a 3 niveaux pour un champ obligatoire : colonne `nullable` (DB) <-> `Assert\NotBlank` (back) <-> `:required` + asterisque (front ERP-101). Les trois doivent s'accorder. + +Exceptions au miroir `Length` : un format deja borne par `Assert\Bic` / `Assert\Iban` (longueur garantie) ou par un `Assert\Regex` borne (ex. code postal `{4,5}`, couleur hex `#RRGGBB`) — whitelister alors la propriete dans `EntityConstraintsHaveFrenchMessageTest::EXCLUDED_LENGTH_MIRROR` avec justification. + +Les regles inter-champs (RG metier : exclusivite distributor/broker RG-1.03, billingEmail RG-1.11, etc.) passent par un `#[Assert\Callback]` qui construit la violation avec `->atPath('')` — indispensable pour que le front la mappe en inline plutot qu'en toast. + +### Garde-fou architecture + +`tests/Architecture/EntityConstraintsHaveFrenchMessageTest` scanne reflexivement les entites sous `src/Module/*/Domain/Entity/` et echoue si : +1. une contrainte connue n'a pas de message FR explicite (compare au defaut Symfony) ; +2. une colonne string bornee writable n'a pas de `Assert\Length(max == ORM length)` (hors whitelist). + +Une contrainte non geree par le mapping du test le fait echouer : il faut l'ajouter explicitement (anti faux positif vert). + ## API Platform (pas de controllers) - Toujours utiliser `#[ApiResource]` + Providers + Processors — pas de controllers Symfony classiques diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index fec9242..2df7950 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -108,6 +108,18 @@ A NE PAS faire : - Seuls les deep links "de navigation metier" (ex: ouvrir un detail precis `/users/42`) sont dans l'URL - Exceptions autorisees **sur demande explicite** de l'utilisateur +## Validation des formulaires (standard ERP-101) + +Regle transverse a TOUS les formulaires front (et a rappeler a l'ecriture de chaque ticket back/front portant un formulaire). Decidee en ERP-101 (declencheur : ecran « Ajouter un client » ERP-63). + +- **Champs obligatoires** : prop `required` du composant `Malio*` + etoile (asterisque) rouge dans le label. Ne JAMAIS griser le bouton « Valider » sans feedback : bouton toujours actif + erreurs affichees sous les champs. +- **Couche de validation autoritaire = le back** : les RG sont re-validees serveur (mode strict). Au `422`, mapper `violations[].propertyPath` vers la prop `error` du champ via `extractApiViolations` (deja utilise par `useCategoryForm`). Zero duplication de RG, zero drift. +- **Feedback instantane au blur** : uniquement requis / min / max / format (pas de re-implementation des RG metier cote front). +- **Regles front-only** : celles sans equivalent back (ex. FK nullable cote back mais obligatoire selon un choix UI) sont validees et affichees cote front. +- **Email — PAS de masque** : un email n'a pas de structure fixe. Normalisation via la prop `lowercase` de `MalioInputEmail` (trim + suppression des espaces + lowercase, coherent avec la normalisation serveur RG-1.21). Le format est valide par la prop `error` (violations serveur ou check au blur), jamais par un masque. Retirer tout shaping email ad hoc des ecrans. +- **Contrat back attendu** : tout `422` issu d'un Processor/Validator doit porter `violations[].propertyPath` aligne sur les noms de champs du formulaire, pour etre consommable par `extractApiViolations`. +- **Dependance** : le branchement des props `required` suppose `@malio/layer-ui` a jour (props `required` + etoile — MUI-41 / ERP-101). + ## Interdits - `modules-loader.ts`, `.module.ts` — le scan des layers est automatique diff --git a/composer.json b/composer.json index b4bf311..b7deb03 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,7 @@ "symfony/runtime": "8.0.*", "symfony/security-bundle": "8.0.*", "symfony/serializer": "8.0.*", + "symfony/translation": "8.0.*", "symfony/twig-bundle": "8.0.*", "symfony/uid": "8.0.*", "symfony/validator": "8.0.*", diff --git a/composer.lock b/composer.lock index 4bf713f..649a02d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aada2e60fd7563f1498b5505b37e3f4b", + "content-hash": "2dc5db01e7f5d6aecd5956749b21a092", "packages": [ { "name": "api-platform/doctrine-common", @@ -7657,6 +7657,99 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "symfony/translation", + "version": "v8.0.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-06T11:30:54+00:00" + }, { "name": "symfony/translation-contracts", "version": "v3.6.1", diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..cdf3aa2 --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,12 @@ +framework: + # Locale par defaut FR (ERP-107) : les messages natifs des contraintes + # Symfony (Email, NotBlank, Length, Iban, Bic...) sont alors servis en + # francais via validators.fr.xlf. C'est le FILET ; les contraintes metier + # portent en plus un `message:` FR explicite, teste par + # tests/Architecture/EntityConstraintsHaveFrenchMessageTest. + default_locale: fr + translator: + default_path: '%kernel.project_dir%/translations' + fallbacks: + - fr + providers: diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 3c55a94..d0dea19 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -106,7 +106,7 @@ class Category implements TimestampableInterface, BlamableInterface, CategoryInt // persiste, sans contradiction entre l'ordre Validate / Process. #[ORM\Column(length: 120)] #[Assert\NotBlank(message: 'Le nom est obligatoire.', normalizer: 'trim')] - #[Assert\Length(min: 2, max: 120, normalizer: 'trim')] + #[Assert\Length(min: 2, max: 120, minMessage: 'Le nom doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['category:read', 'category:write'])] private ?string $name = null; diff --git a/src/Module/Commercial/Domain/Entity/Client.php b/src/Module/Commercial/Domain/Entity/Client.php index bdcac63..6b8c78b 100644 --- a/src/Module/Commercial/Domain/Entity/Client.php +++ b/src/Module/Commercial/Domain/Entity/Client.php @@ -147,33 +147,36 @@ class Client implements TimestampableInterface, BlamableInterface // === Formulaire principal === #[ORM\Column(length: 180)] #[Assert\NotBlank(message: 'Le nom de l\'entreprise est obligatoire.', normalizer: 'trim')] - #[Assert\Length(min: 2, max: 180, normalizer: 'trim')] + #[Assert\Length(min: 2, max: 180, minMessage: 'Le nom de l\'entreprise doit comporter au moins {{ limit }} caractères.', maxMessage: 'Le nom de l\'entreprise ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $companyName = null; // RG-1.01 : firstName OU lastName obligatoire (validation au futur Processor). #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] + #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $firstName = null; #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] + #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $lastName = null; #[ORM\Column(length: 20)] - #[Assert\NotBlank] + #[Assert\NotBlank(message: 'Le téléphone est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $phonePrimary = null; #[ORM\Column(length: 20, nullable: true)] + #[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $phoneSecondary = null; #[ORM\Column(length: 180)] - #[Assert\NotBlank] - #[Assert\Email] + #[Assert\NotBlank(message: 'L\'adresse email est obligatoire.', normalizer: 'trim')] + #[Assert\Email(message: 'L\'adresse email n\'est pas valide.')] + #[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:main'])] private ?string $email = null; @@ -210,6 +213,7 @@ class Client implements TimestampableInterface, BlamableInterface private ?string $description = null; #[ORM\Column(length: 255, nullable: true)] + #[Assert\Length(max: 255, maxMessage: 'Ce champ ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:information'])] private ?string $competitors = null; @@ -218,7 +222,7 @@ class Client implements TimestampableInterface, BlamableInterface private ?DateTimeImmutable $foundedAt = null; #[ORM\Column(nullable: true)] - #[Assert\PositiveOrZero] + #[Assert\PositiveOrZero(message: 'L\'effectif doit être un nombre positif ou nul.')] #[Groups(['client:read', 'client:write:information'])] private ?int $employeesCount = null; @@ -227,6 +231,7 @@ class Client implements TimestampableInterface, BlamableInterface private ?string $revenueAmount = null; #[ORM\Column(length: 120, nullable: true)] + #[Assert\Length(max: 120, maxMessage: 'Le nom du dirigeant ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read', 'client:write:information'])] private ?string $directorName = null; @@ -239,10 +244,12 @@ class Client implements TimestampableInterface, BlamableInterface // futur Provider si l'user a la permission accounting.view). Ecriture via // `client:write:accounting` (le futur Processor exige accounting.manage). #[ORM\Column(length: 20, nullable: true)] + #[Assert\Length(max: 20, maxMessage: 'Le SIREN ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $siren = null; #[ORM\Column(length: 40, nullable: true)] + #[Assert\Length(max: 40, maxMessage: 'Le numéro de compte ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $accountNumber = null; @@ -252,6 +259,7 @@ class Client implements TimestampableInterface, BlamableInterface private ?TvaMode $tvaMode = null; #[ORM\Column(length: 40, nullable: true)] + #[Assert\Length(max: 40, maxMessage: 'Le numéro de TVA ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client:read:accounting', 'client:write:accounting'])] private ?string $nTva = null; diff --git a/src/Module/Commercial/Domain/Entity/ClientAddress.php b/src/Module/Commercial/Domain/Entity/ClientAddress.php index c359088..9a15adb 100644 --- a/src/Module/Commercial/Domain/Entity/ClientAddress.php +++ b/src/Module/Commercial/Domain/Entity/ClientAddress.php @@ -125,33 +125,39 @@ class ClientAddress implements TimestampableInterface, BlamableInterface private bool $isBilling = false; #[ORM\Column(length: 80, options: ['default' => 'France'])] + #[Assert\Length(max: 80, maxMessage: 'Le pays ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_address:read', 'client_address:write'])] private string $country = 'France'; // RG-1.09 : code postal a 4 ou 5 chiffres (pas de controle CP/ville serveur). + // Le Regex borne deja la longueur (≤ 5) : pas de Length redondant. #[ORM\Column(length: 20)] - #[Assert\NotBlank] + #[Assert\NotBlank(message: 'Le code postal est obligatoire.', normalizer: 'trim')] #[Assert\Regex(pattern: '/^[0-9]{4,5}$/', message: 'Le code postal doit comporter 4 ou 5 chiffres.')] #[Groups(['client_address:read', 'client_address:write'])] private ?string $postalCode = null; #[ORM\Column(length: 120)] - #[Assert\NotBlank] + #[Assert\NotBlank(message: 'La ville est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 120, maxMessage: 'La ville ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_address:read', 'client_address:write'])] private ?string $city = null; #[ORM\Column(length: 255)] - #[Assert\NotBlank] + #[Assert\NotBlank(message: 'La rue est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 255, maxMessage: 'La rue ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_address:read', 'client_address:write'])] private ?string $street = null; #[ORM\Column(length: 255, nullable: true)] + #[Assert\Length(max: 255, maxMessage: 'Le complément d\'adresse ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_address:read', 'client_address:write'])] private ?string $streetComplement = null; // RG-1.11 : obligatoire ssi isBilling (validateBillingEmailPresence + CHECK BDD). #[ORM\Column(length: 180, nullable: true)] - #[Assert\Email] + #[Assert\Email(message: 'L\'email de facturation n\'est pas valide.')] + #[Assert\Length(max: 180, maxMessage: 'L\'email de facturation ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_address:read', 'client_address:write'])] private ?string $billingEmail = null; diff --git a/src/Module/Commercial/Domain/Entity/ClientContact.php b/src/Module/Commercial/Domain/Entity/ClientContact.php index 06565e2..b28aaa2 100644 --- a/src/Module/Commercial/Domain/Entity/ClientContact.php +++ b/src/Module/Commercial/Domain/Entity/ClientContact.php @@ -88,30 +88,36 @@ class ClientContact implements TimestampableInterface, BlamableInterface // RG-1.05 : firstName OU lastName obligatoire (CHECK BDD + Processor). Les // deux restent nullable au niveau ORM. #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] + #[Assert\Length(max: 120, maxMessage: 'Le prénom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_contact:read', 'client_contact:write'])] private ?string $firstName = null; #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] + #[Assert\Length(max: 120, maxMessage: 'Le nom ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_contact:read', 'client_contact:write'])] private ?string $lastName = null; #[ORM\Column(length: 120, nullable: true)] - #[Assert\Length(max: 120, normalizer: 'trim')] + #[Assert\Length(max: 120, maxMessage: 'La fonction ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_contact:read', 'client_contact:write'])] private ?string $jobTitle = null; + // RG : pas de validation de format telephone (saisie libre), mais une + // Assert\Length calee sur la colonne VARCHAR(20) evite l'erreur Postgres + // (500 non rattachee au champ) au profit d'une 422 propre (ERP-107). #[ORM\Column(length: 20, nullable: true)] + #[Assert\Length(max: 20, maxMessage: 'Le téléphone ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_contact:read', 'client_contact:write'])] private ?string $phonePrimary = null; #[ORM\Column(length: 20, nullable: true)] + #[Assert\Length(max: 20, maxMessage: 'Le téléphone secondaire ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_contact:read', 'client_contact:write'])] private ?string $phoneSecondary = null; #[ORM\Column(length: 180, nullable: true)] - #[Assert\Email] + #[Assert\Email(message: 'L\'adresse email n\'est pas valide.')] + #[Assert\Length(max: 180, maxMessage: 'L\'email ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_contact:read', 'client_contact:write'])] private ?string $email = null; diff --git a/src/Module/Commercial/Domain/Entity/ClientRib.php b/src/Module/Commercial/Domain/Entity/ClientRib.php index aa5eba5..956ee80 100644 --- a/src/Module/Commercial/Domain/Entity/ClientRib.php +++ b/src/Module/Commercial/Domain/Entity/ClientRib.php @@ -97,20 +97,22 @@ class ClientRib implements TimestampableInterface, BlamableInterface private ?Client $client = null; #[ORM\Column(length: 120)] - #[Assert\NotBlank] - #[Assert\Length(max: 120, normalizer: 'trim')] + #[Assert\NotBlank(message: 'Le libellé du RIB est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 120, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] private ?string $label = null; + // Bic/Iban bornent deja le format (et donc la longueur) : pas de Length + // redondant calee sur la colonne (whitelist du garde-fou ERP-107). #[ORM\Column(length: 20)] - #[Assert\NotBlank] - #[Assert\Bic] + #[Assert\NotBlank(message: 'Le BIC est obligatoire.', normalizer: 'trim')] + #[Assert\Bic(message: 'Le BIC n\'est pas valide.')] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] private ?string $bic = null; #[ORM\Column(length: 34)] - #[Assert\NotBlank] - #[Assert\Iban] + #[Assert\NotBlank(message: 'L\'IBAN est obligatoire.', normalizer: 'trim')] + #[Assert\Iban(message: 'L\'IBAN n\'est pas valide.')] #[Groups(['client_rib:read', 'client:read:accounting', 'client_rib:write'])] private ?string $iban = null; diff --git a/src/Module/Core/Domain/Entity/Role.php b/src/Module/Core/Domain/Entity/Role.php index a6b4bcf..43deada 100644 --- a/src/Module/Core/Domain/Entity/Role.php +++ b/src/Module/Core/Domain/Entity/Role.php @@ -79,13 +79,15 @@ class Role #[ORM\Column(length: 100)] #[Groups(['role:read', 'role:write'])] - #[Assert\NotBlank] - #[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit etre en snake_case et commencer par une lettre minuscule.')] + #[Assert\NotBlank(message: 'Le code du rôle est obligatoire.', normalizer: 'trim')] + #[Assert\Regex(pattern: '/^[a-z][a-z0-9_]*$/', message: 'Le code doit être en snake_case et commencer par une lettre minuscule.')] + #[Assert\Length(max: 100, maxMessage: 'Le code ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] private string $code; #[ORM\Column(length: 255)] #[Groups(['role:read', 'role:write'])] - #[Assert\NotBlank] + #[Assert\NotBlank(message: 'Le libellé du rôle est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 255, maxMessage: 'Le libellé ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] private string $label; #[ORM\Column(type: Types::TEXT, nullable: true)] diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 8016543..09d0a85 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -33,6 +33,7 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ @@ -85,6 +86,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Busines private ?int $id = null; #[ORM\Column(length: 180, unique: true)] + #[Assert\NotBlank(message: 'Le nom d\'utilisateur est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 180, maxMessage: 'Le nom d\'utilisateur ne peut dépasser {{ limit }} caractères.', normalizer: 'trim')] #[Groups(['me:read', 'user:list', 'user:write'])] private ?string $username = null; diff --git a/symfony.lock b/symfony.lock index 9b7ba43..7d9505d 100644 --- a/symfony.lock +++ b/symfony.lock @@ -219,6 +219,19 @@ "config/routes/security.yaml" ] }, + "symfony/translation": { + "version": "8.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + }, "symfony/twig-bundle": { "version": "8.0", "recipe": { diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php new file mode 100644 index 0000000..9673d5b --- /dev/null +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -0,0 +1,363 @@ +::". + * + * @var array + */ + private const array EXCLUDED_LENGTH_MIRROR = [ + // Le Regex /^[0-9]{4,5}$/ borne deja la longueur a 5 caracteres (< 20). + 'ClientAddress::postalCode' => 'Regex {4,5} borne deja la longueur.', + // Le Regex /^#[0-9A-Fa-f]{6}$/ borne la longueur a exactement 7 caracteres. + 'Site::color' => 'Regex code hex #RRGGBB borne deja la longueur.', + ]; + + /** + * Mapping contrainte -> proprietes de message a verifier. Une contrainte + * absente de ce mapping (hors Callback) fait ECHOUER le test : il faut + * l'ajouter explicitement (anti faux positif vert sur une contrainte inconnue). + * + * Pour Length / Count, la liste est calculee dynamiquement (minMessage si + * `min` est pose, maxMessage si `max` est pose). + * + * @var list> + */ + private const array SIMPLE_MESSAGE_CONSTRAINTS = [ + Assert\NotBlank::class, + Assert\NotNull::class, + Assert\Email::class, + Assert\Regex::class, + Assert\Bic::class, + Assert\Iban::class, + Assert\PositiveOrZero::class, + Assert\Positive::class, + Assert\NegativeOrZero::class, + Assert\Negative::class, + ]; + + public function testEveryConstraintHasAnExplicitFrenchMessage(): void + { + $checked = 0; + + foreach ($this->entityProperties() as [$shortClass, $property]) { + foreach ($property->getAttributes() as $attribute) { + $name = $attribute->getName(); + if (!is_subclass_of($name, Constraint::class)) { + continue; + } + + // Les Callback portent leur message dans la closure : hors scope. + if (Assert\Callback::class === $name) { + continue; + } + + /** @var Constraint $constraint */ + $constraint = $attribute->newInstance(); + $messageProps = $this->messagePropertiesFor($constraint); + + self::assertNotNull( + $messageProps, + sprintf( + 'Contrainte non geree par le garde-fou : %s sur %s::$%s. ' + .'Ajouter sa classe au mapping de EntityConstraintsHaveFrenchMessageTest.', + $name, + $shortClass, + $property->getName(), + ), + ); + + foreach ($messageProps as $prop) { + $actual = $constraint->{$prop} ?? null; + $default = $this->defaultMessageFor($name, $prop); + + self::assertTrue( + is_string($actual) && '' !== $actual && $actual !== $default, + sprintf( + 'La contrainte %s sur %s::$%s n\'a pas de %s FR explicite ' + .'(message absent ou laisse au defaut anglais). Cf. ERP-107.', + $name, + $shortClass, + $property->getName(), + $prop, + ), + ); + ++$checked; + } + } + } + + self::assertGreaterThan(0, $checked, 'Aucune contrainte verifiee : detection d\'attributs cassee ?'); + } + + public function testBoundedStringColumnsHaveMatchingLength(): void + { + $checked = 0; + + foreach ($this->entityProperties() as [$shortClass, $property]) { + $column = $this->ormColumn($property); + if (null === $column || null === $column->length) { + continue; + } + // Colonnes non-string (text, decimal, date...) : pas de length scalaire a calquer. + if (null !== $column->type && 'string' !== $column->type) { + continue; + } + // Le miroir ne protege que la saisie utilisateur (champs writable). + if (!$this->isPropertyWritable($property)) { + continue; + } + + $constraints = $this->constraintsOf($property); + + // Format deja borne par Bic/Iban : longueur garantie cote contrainte. + if ($this->hasAnyConstraint($constraints, [Assert\Bic::class, Assert\Iban::class])) { + continue; + } + + $excludeKey = $shortClass.'::'.$property->getName(); + if (isset(self::EXCLUDED_LENGTH_MIRROR[$excludeKey])) { + continue; + } + + $length = null; + foreach ($constraints as $c) { + if ($c instanceof Assert\Length) { + $length = $c->max; + break; + } + } + + self::assertNotNull( + $length, + sprintf( + '%s::$%s est une colonne string bornee (length=%d) writable sans Assert\Length : ' + .'risque d\'erreur Postgres 500. Ajouter Assert\Length(max: %d) ou whitelister. Cf. ERP-107.', + $shortClass, + $property->getName(), + $column->length, + $column->length, + ), + ); + self::assertSame( + $column->length, + $length, + sprintf( + 'Derive Assert\Length.max (%s) != ORM length (%d) sur %s::$%s. ' + .'Le max doit refleter le length de la colonne (anti-derive ERP-107).', + (string) $length, + $column->length, + $shortClass, + $property->getName(), + ), + ); + ++$checked; + } + + self::assertGreaterThan(0, $checked, 'Aucune colonne string bornee verifiee : scan casse ?'); + } + + /** + * Itere (classe courte, ReflectionProperty) sur toutes les entites metier + * sous src/Module//Domain/Entity/. + * + * @return iterable + */ + private function entityProperties(): iterable + { + $finder = new Finder() + ->files() + ->in(__DIR__.'/../../src/Module') + ->path('Domain/Entity') + ->name('*.php') + ; + + self::assertNotEmpty(iterator_to_array($finder), 'Aucune entite scannee : chemin src/Module invalide ?'); + + foreach ($finder as $file) { + $fqcn = $this->extractFqcn($file->getRealPath()); + if (null === $fqcn) { + continue; + } + + $reflection = new ReflectionClass($fqcn); + if ($reflection->isAbstract()) { + continue; + } + + foreach ($reflection->getProperties() as $property) { + yield [$reflection->getShortName(), $property]; + } + } + } + + /** + * Liste des proprietes de message a verifier pour une contrainte donnee, ou + * null si la contrainte n'est pas geree (le test echoue alors explicitement). + * + * @return list|null + */ + private function messagePropertiesFor(Constraint $constraint): ?array + { + if ($constraint instanceof Assert\Length) { + $props = []; + if (null !== $constraint->min) { + $props[] = 'minMessage'; + } + if (null !== $constraint->max) { + $props[] = 'maxMessage'; + } + + return $props; + } + + if ($constraint instanceof Assert\Count) { + $props = []; + if (null !== $constraint->min) { + $props[] = 'minMessage'; + } + if (null !== $constraint->max) { + $props[] = 'maxMessage'; + } + + return $props; + } + + if (in_array($constraint::class, self::SIMPLE_MESSAGE_CONSTRAINTS, true)) { + return ['message']; + } + + return null; + } + + /** + * Message par defaut d'une contrainte (instance « nue ») pour la propriete + * demandee. Sert de reference pour detecter un message laisse au defaut. + */ + private function defaultMessageFor(string $class, string $prop): ?string + { + $bare = match ($class) { + Assert\Length::class => new Assert\Length(max: 1), + Assert\Count::class => new Assert\Count(min: 1), + Assert\Regex::class => new Assert\Regex(pattern: '/^x$/'), + default => new $class(), + }; + + $value = $bare->{$prop} ?? null; + + return is_string($value) ? $value : null; + } + + private function ormColumn(ReflectionProperty $property): ?Column + { + $attrs = $property->getAttributes(Column::class); + + return [] === $attrs ? null : $attrs[0]->newInstance(); + } + + /** @return list */ + private function constraintsOf(ReflectionProperty $property): array + { + $out = []; + foreach ($property->getAttributes() as $attribute) { + if (is_subclass_of($attribute->getName(), Constraint::class)) { + $out[] = $attribute->newInstance(); + } + } + + return $out; + } + + /** + * @param list $constraints + * @param list> $classes + */ + private function hasAnyConstraint(array $constraints, array $classes): bool + { + foreach ($constraints as $c) { + if (in_array($c::class, $classes, true)) { + return true; + } + } + + return false; + } + + private function isPropertyWritable(ReflectionProperty $property): bool + { + $attrs = $property->getAttributes(Groups::class); + if ([] === $attrs) { + return false; + } + + /** @var Groups $groups */ + $groups = $attrs[0]->newInstance(); + foreach ($groups->groups as $group) { + if (is_string($group) && str_contains($group, 'write')) { + return true; + } + } + + return false; + } + + private function extractFqcn(string $path): ?string + { + $source = file_get_contents($path); + if (false === $source) { + return null; + } + + if ( + 1 !== preg_match('/^namespace\s+([^;]+);/m', $source, $nsMatch) + || 1 !== preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $source, $classMatch) + ) { + return null; + } + + return trim($nsMatch[1]).'\\'.$classMatch[1]; + } +} diff --git a/tests/Module/Commercial/Api/ClientApiTest.php b/tests/Module/Commercial/Api/ClientApiTest.php index 90827cc..d0e1396 100644 --- a/tests/Module/Commercial/Api/ClientApiTest.php +++ b/tests/Module/Commercial/Api/ClientApiTest.php @@ -95,6 +95,38 @@ final class ClientApiTest extends AbstractCommercialApiTestCase self::assertResponseStatusCodeSame(422); } + /** + * ERP-107 : une violation de contrainte sort avec un message FR explicite ET + * un `propertyPath` rattache au champ (consommable par useFormErrors / + * mapViolationsToRecord cote front, ERP-101). On verifie le JSON 422 reel. + */ + public function testInvalidEmailReturns422WithFrenchMessageOnField(): void + { + $client = $this->createAdminClient(); + $cat = $this->createCategory('SECTEUR'); + + $response = $client->request('POST', '/api/clients', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => [ + 'companyName' => 'Bad Email Inc', + 'firstName' => 'A', + 'phonePrimary' => '0102030405', + 'email' => 'pas-un-email', + 'categories' => ['/api/categories/'.$cat->getId()], + ], + ]); + + self::assertResponseStatusCodeSame(422); + $violations = $response->toArray(false)['violations'] ?? []; + $byPath = []; + foreach ($violations as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + + self::assertArrayHasKey('email', $byPath, 'La violation email doit porter propertyPath=email (mapping front).'); + self::assertSame('L\'adresse email n\'est pas valide.', $byPath['email']); + } + public function testPostWithoutCategoryReturns422(): void { $client = $this->createAdminClient(); diff --git a/translations/.gitignore b/translations/.gitignore new file mode 100644 index 0000000..e18ae7b --- /dev/null +++ b/translations/.gitignore @@ -0,0 +1,2 @@ +# Les traductions natives FR viennent du vendor (validators.fr.xlf). +# Ce dossier accueille les overrides applicatifs eventuels.