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).
This commit is contained in:
@@ -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('<champ>')` — 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*",
|
||||
|
||||
Generated
+94
-1
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Architecture;
|
||||
|
||||
use Doctrine\ORM\Mapping\Column;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
use ReflectionProperty;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
use function in_array;
|
||||
use function is_string;
|
||||
use function sprintf;
|
||||
use function str_contains;
|
||||
|
||||
/**
|
||||
* Garde-fou architecture ERP-107 : toute contrainte `#[Assert\*]` portee par une
|
||||
* entite metier doit avoir un message FR EXPLICITE (et non le defaut anglais de
|
||||
* Symfony), et toute colonne string bornee writable doit avoir une `Assert\Length`
|
||||
* calee sur le `length` de la colonne ORM.
|
||||
*
|
||||
* Pourquoi (lien ERP-101) : le front (useFormErrors / mapViolationsToRecord)
|
||||
* affiche sous chaque champ le `message` renvoye par le back. Un message absent
|
||||
* = defaut anglais ; une colonne bornee sans Assert\Length = erreur Postgres
|
||||
* (500) au lieu d'une 422 propre rattachee au champ.
|
||||
*
|
||||
* Deux verifications, sur le modele de AuditableEntitiesHaveI18nLabelTest :
|
||||
* 1. MESSAGE EXPLICITE : pour chaque contrainte connue, la (ou les) propriete(s)
|
||||
* de message pertinente(s) doivent differer du defaut Symfony. La comparaison
|
||||
* au defaut (instance « nue » de la meme contrainte) evite de valider un
|
||||
* message anglais natif laisse tel quel.
|
||||
* 2. LENGTH == ORM length : toute propriete string writable avec `ORM\Column(length:)`
|
||||
* doit porter `Assert\Length(max:)` egal a ce length — sauf si le format est
|
||||
* deja borne par Bic/Iban, ou whitelistee dans EXCLUDED_LENGTH_MIRROR.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class EntityConstraintsHaveFrenchMessageTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Proprietes writable exemptees du miroir Assert\Length == ORM length, avec
|
||||
* justification. Toute entree doit citer la raison (format deja borne par une
|
||||
* autre contrainte). Cle : "<ClasseCourte>::<propriete>".
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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<class-string<Constraint>>
|
||||
*/
|
||||
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/<m>/Domain/Entity/.
|
||||
*
|
||||
* @return iterable<array{0: string, 1: ReflectionProperty}>
|
||||
*/
|
||||
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<string>|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<Constraint> */
|
||||
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<Constraint> $constraints
|
||||
* @param list<class-string<Constraint>> $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];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Les traductions natives FR viennent du vendor (validators.fr.xlf).
|
||||
# Ce dossier accueille les overrides applicatifs eventuels.
|
||||
Reference in New Issue
Block a user