feat(commercial) : messages de validation FR sur les contraintes back + garde-fou (ERP-107)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m45s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m12s

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:
Matthieu
2026-06-04 10:11:33 +02:00
parent 97301dcd6c
commit 9202d2ee6f
16 changed files with 617 additions and 26 deletions
+36
View File
@@ -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
+12
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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",
+12
View File
@@ -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;
+15 -7
View File
@@ -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;
+5 -3
View File
@@ -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)]
+3
View File
@@ -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;
+13
View File
@@ -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();
+2
View File
@@ -0,0 +1,2 @@
# Les traductions natives FR viennent du vendor (validators.fr.xlf).
# Ce dossier accueille les overrides applicatifs eventuels.