fix(catalog) : M7 — durcissement stockages (états JSONB séquentiels + Assert\Unique, neutralisation injection formules XLSX partagée, parité listing/export via StorageListFilters, streaming export)

- Storage.setStates() renormalise en liste séquentielle (array_values) : un states posté en objet JSON ne peut plus être persisté en JSONB objet (jsonb_array_length → 500). Doublons rejetés en 422 via Assert\Unique.
- PhpSpreadsheetExporter écrit les cellules chaîne en TYPE_STRING explicite : neutralise l'injection de formules/DDE sur toutes les valeurs saisies (corrige aussi Produit/Client/Logistique/Supplier/Provider/Carrier).
- StorageListFilters : source unique de parsing des filtres (?search, ?siteId[], ?storageTypeId, ?state), consommée par le provider ET l'export → fin des divergences (numéro « 0 » coercé à null, param tableau en 400, id non positif).
- Export en streaming (toIterable + clear par lot) au lieu de getResult() : mémoire bornée.
- Tests : doublon/objet states, normalisation trim RG-7.06, 422 relations nulles, absence de deletedAt, soft-delete liste discriminant, neutralisation formule, parité ?search=0, robustesse param tableau ; garde-fou Assert\Unique enregistré.
This commit is contained in:
2026-06-29 18:01:54 +02:00
parent caa558f582
commit 7075f0f95d
10 changed files with 392 additions and 157 deletions
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Module\Catalog\Application\Filter;
use App\Module\Catalog\Domain\Entity\Storage;
use function in_array;
use function is_array;
use function is_int;
use function is_string;
/**
* Filtres de liste des stockages : SOURCE UNIQUE de verite du parsing des parametres
* de requete (?search sur numero, ?siteId[], ?storageTypeId, ?state). Partagee par le
* StorageProvider (liste paginee) et le StorageExportController (export XLSX) pour
* garantir que l'export reflete EXACTEMENT ce que l'utilisateur voit a l'ecran.
*
* Sans cette factorisation, les deux endpoints parsaient les memes filtres avec des
* regles subtilement differentes (numero litteral « 0 » coerce a null cote export,
* id non positif accepte cote liste mais ignore cote export, parametre tableau
* jetant un 400 cote export) : autant de divergences liste/export. Une seule
* implementation -> zero drift, chaque nouveau filtre se branche en un seul endroit.
*/
final readonly class StorageListFilters
{
/** Etats valides du filtre ?state= (enum borne, RG-7.04). */
private const array VALID_STATES = [
Storage::STATE_RECEPTION,
Storage::STATE_PRODUCTION,
Storage::STATE_TRIAGE,
];
/**
* @param list<int> $siteIds
*/
private function __construct(
public ?string $search,
public array $siteIds,
public ?int $storageTypeId,
public ?string $state,
) {}
/**
* Construit les filtres depuis une source brute : le `$context['filters']`
* d'API Platform cote provider, ou `$request->query->all()` cote controller
* d'export. Tolere scalaire ou tableau, ignore les entrees invalides — jamais
* d'exception sur une saisie malformee (ex: `?search[]=x`).
*
* @param array<string, mixed> $query
*/
public static function fromQuery(array $query): self
{
return new self(
self::readSearch($query['search'] ?? null),
self::readSiteIds($query['siteId'] ?? null),
self::readPositiveInt($query['storageTypeId'] ?? null),
self::readState($query['state'] ?? null),
);
}
/**
* Recherche partielle sur numero : valeur trimmee, ou null si absente / vide.
* La chaine « 0 » est un numero valide (VARCHAR) et N'EST PAS coercee a null.
*/
private static function readSearch(mixed $raw): ?string
{
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Liste d'identifiants de sites (OR). Tolere une valeur scalaire unique
* (`?siteId=1`) ou un tableau (`?siteId[]=1&siteId[]=2`), dedup, ordre stable.
*
* @return list<int>
*/
private static function readSiteIds(mixed $raw): array
{
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
$id = self::readPositiveInt($value);
if (null !== $id) {
$ids[] = $id;
}
}
return array_values(array_unique($ids));
}
/**
* Identifiant entier STRICTEMENT POSITIF (un id metier l'est toujours) ou null.
* Un 0 ou un negatif est traite comme « pas de filtre », jamais comme un id
* impossible (qui renverrait une liste vide cote provider mais tout cote export).
*/
private static function readPositiveInt(mixed $raw): ?int
{
if (is_int($raw)) {
return $raw > 0 ? $raw : null;
}
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
}
/**
* Filtre ?state= : normalise en majuscules, n'accepte qu'une valeur de l'enum
* borne {RECEPTION, PRODUCTION, TRIAGE} ; toute autre valeur est ignoree (null).
*/
private static function readState(mixed $raw): ?string
{
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, self::VALID_STATES, true) ? $state : null;
}
}
+7 -1
View File
@@ -158,6 +158,7 @@ class Storage implements TimestampableInterface, BlamableInterface
// qui casse le CHECK et fait echouer make test-db-setup (cf. Product::states).
#[ORM\Column(type: 'json', options: ['jsonb' => true])]
#[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état.')]
#[Assert\Unique(message: 'Chaque état ne peut être sélectionné qu\'une seule fois.')]
#[Assert\Choice(
choices: [self::STATE_RECEPTION, self::STATE_PRODUCTION, self::STATE_TRIAGE],
multiple: true,
@@ -230,7 +231,12 @@ class Storage implements TimestampableInterface, BlamableInterface
*/
public function setStates(array $states): static
{
$this->states = $states;
// `array_values` reseque toujours un tableau SEQUENTIEL : une saisie cliente
// malformee (objet JSON `{"x":"RECEPTION"}` denormalise en tableau associatif)
// ne peut plus etre persistee comme un objet JSONB, ce qui ferait echouer le
// CHECK chk_storage_states_not_empty (jsonb_array_length sur non-tableau) en
// 500. Les doublons eventuels restent rejetes en 422 par Assert\Unique.
$this->states = array_values($states);
return $this;
}
@@ -9,12 +9,12 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Catalog\Application\Filter\StorageListFilters;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use function in_array;
use function is_int;
use function is_string;
@@ -22,8 +22,9 @@ use function is_string;
* Provider Storage (lecture, ERP-213) :
* - LISTE : exclut par defaut les stockages soft-deleted (RG-7.07), trie par
* site.code ASC, storageType.label ASC, numero ASC (defaut spec § 4.1), applique
* les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state) et renvoie
* une collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
* les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state — parses par
* {@see StorageListFilters}, source partagee avec l'export) et renvoie une
* collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une
* operation de collection — on enveloppe le QueryBuilder dans le Paginator ORM).
* Echappatoire ?pagination=false respectee (alimentation d'un select).
* - ITEM : recharge le stockage puis renvoie null (404) s'il est soft-deleted — le
@@ -33,13 +34,6 @@ use function is_string;
*/
final class StorageProvider implements ProviderInterface
{
/** Etats valides du filtre ?state= (enum borne, RG-7.04). */
private const array VALID_STATES = [
Storage::STATE_RECEPTION,
Storage::STATE_PRODUCTION,
Storage::STATE_TRIAGE,
];
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
private readonly StorageRepositoryInterface $repository,
@@ -49,13 +43,16 @@ final class StorageProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Storage|null
{
if ($operation instanceof CollectionOperationInterface) {
// Filtres parses par la source partagee avec l'export (parite garantie).
$filters = StorageListFilters::fromQuery($context['filters'] ?? []);
// includeDeleted toujours false : le soft-delete n'est pas expose (§ 2.8).
$qb = $this->repository->createListQueryBuilder(
false,
$this->readSearch($context),
$this->readSiteIds($context),
$this->readStorageTypeId($context),
$this->readState($context),
$filters->search,
$filters->siteIds,
$filters->storageTypeId,
$filters->state,
);
// Echappatoire ?pagination=false : collection complete sans Paginator.
@@ -93,80 +90,4 @@ final class StorageProvider implements ProviderInterface
return $storage;
}
/**
* Lit le filtre `?search=` (recherche partielle sur numero). Renvoie la valeur
* trimmee ou null si absente / vide.
*/
private function readSearch(array $context): ?string
{
$raw = $context['filters']['search'] ?? null;
if (!is_string($raw)) {
return null;
}
$raw = trim($raw);
return '' === $raw ? null : $raw;
}
/**
* Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur
* scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques.
*
* @return list<int>
*/
private function readSiteIds(array $context): array
{
$raw = $context['filters']['siteId'] ?? null;
if (null === $raw) {
return [];
}
$values = is_array($raw) ? $raw : [$raw];
$ids = [];
foreach ($values as $value) {
if (is_int($value) || (is_string($value) && ctype_digit($value))) {
$ids[] = (int) $value;
}
}
return array_values(array_unique($ids));
}
/**
* Lit le filtre `?storageTypeId=` (drawer « Filtrer »). Renvoie l'id entier ou
* null si absent / non numerique.
*/
private function readStorageTypeId(array $context): ?int
{
$raw = $context['filters']['storageTypeId'] ?? null;
if (is_int($raw)) {
return $raw;
}
return is_string($raw) && ctype_digit($raw) ? (int) $raw : null;
}
/**
* Lit le filtre `?state=` (RECEPTION / PRODUCTION / TRIAGE). Normalise en
* majuscules et n'accepte qu'une valeur de l'enum borne ; toute autre valeur est
* ignoree (null).
*/
private function readState(array $context): ?string
{
$raw = $context['filters']['state'] ?? null;
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, self::VALID_STATES, true) ? $state : null;
}
}
@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Module\Catalog\Infrastructure\Controller;
use App\Module\Catalog\Application\Filter\StorageListFilters;
use App\Module\Catalog\Domain\Entity\Storage;
use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -17,8 +19,6 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use function in_array;
use function is_int;
use function is_string;
/**
* Export XLSX de la liste des stockages (M7, spec-back § 4.5). Jumeau du
@@ -57,10 +57,17 @@ final class StorageExportController
Storage::STATE_TRIAGE => 'Triage',
];
/**
* Taille du lot avant `EntityManager::clear()` pendant le streaming des lignes :
* borne la memoire (identity map) sur un gros export sans tout materialiser.
*/
private const int EXPORT_BATCH_SIZE = 200;
public function __construct(
#[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')]
private readonly StorageRepositoryInterface $repository,
private readonly SpreadsheetExporterInterface $exporter,
private readonly EntityManagerInterface $em,
) {}
#[Route('/api/storages/export.xlsx', name: 'catalog_storages_export_xlsx', methods: ['GET'], priority: 1)]
@@ -69,18 +76,18 @@ final class StorageExportController
{
// Memes filtres que la vue liste (StorageProvider) pour que l'export reflete
// exactement ce que l'utilisateur voit a l'ecran : recherche (?search sur
// numero), sites (?siteId[]), type (?storageTypeId), etat (?state).
// numero), sites (?siteId[]), type (?storageTypeId), etat (?state). Parses par
// la MEME source que le provider ({@see StorageListFilters}) -> aucune
// divergence possible (numero « 0 », parametre tableau, id non positif).
// includeDeleted reste false : le soft-delete n'est jamais expose (§ 2.8).
$search = $request->query->getString('search') ?: null;
$siteIds = $this->readIntList($request->query->all()['siteId'] ?? []);
$storageTypeId = $this->readIntOrNull($request->query->get('storageTypeId'));
$state = $this->readState($request->query->get('state'));
$filters = StorageListFilters::fromQuery($request->query->all());
/** @var list<Storage> $storages */
// Streaming via toIterable() : on ne materialise pas toute la table en memoire
// (cf. buildRows + EXPORT_BATCH_SIZE) avant de construire le classeur.
$storages = $this->repository
->createListQueryBuilder(false, $search, $siteIds, $storageTypeId, $state)
->createListQueryBuilder(false, $filters->search, $filters->siteIds, $filters->storageTypeId, $filters->state)
->getQuery()
->getResult()
->toIterable()
;
$binary = $this->exporter->export(
@@ -111,12 +118,18 @@ final class StorageExportController
}
/**
* @param list<Storage> $storages
* Mappe chaque stockage en ligne d'export, en consommant un iterable paresseux
* (Doctrine `toIterable()`). Toutes les N lignes (EXPORT_BATCH_SIZE), on vide
* l'identity map (`clear()`) pour borner la memoire sur un gros export — sans
* danger ici, le controller ne fait que lire.
*
* @param iterable<Storage> $storages
*
* @return iterable<list<null|scalar>>
*/
private function buildRows(array $storages): iterable
private function buildRows(iterable $storages): iterable
{
$count = 0;
foreach ($storages as $storage) {
yield [
$storage->getDisplayName(),
@@ -127,6 +140,10 @@ final class StorageExportController
$this->formatDate($storage->getCreatedAt()),
$this->formatDate($storage->getUpdatedAt()),
];
if (0 === ++$count % self::EXPORT_BATCH_SIZE) {
$this->em->clear();
}
}
}
@@ -182,53 +199,4 @@ final class StorageExportController
return $response;
}
/**
* Lit le filtre `?state=` comme le StorageProvider : normalise en majuscules et
* n'accepte qu'une valeur de l'enum borne {RECEPTION, PRODUCTION, TRIAGE} ;
* toute autre valeur est ignoree (null).
*/
private function readState(mixed $raw): ?string
{
if (!is_string($raw) || '' === trim($raw)) {
return null;
}
$state = mb_strtoupper(trim($raw), 'UTF-8');
return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null;
}
/**
* Lit un identifiant entier positif unique (`?storageTypeId=`). Aligne sur
* StorageProvider (tolere int ou chaine numerique).
*/
private function readIntOrNull(mixed $raw): ?int
{
if (is_int($raw)) {
return $raw > 0 ? $raw : null;
}
return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null;
}
/**
* Normalise un filtre en liste d'identifiants entiers positifs (valeur unique
* ou liste, `?siteId[]=`). Aligne sur StorageProvider.
*
* @return list<int>
*/
private function readIntList(mixed $raw): array
{
$values = is_array($raw) ? $raw : [$raw];
$out = [];
foreach ($values as $value) {
if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) {
$out[] = (int) $value;
}
}
return $out;
}
}
@@ -5,10 +5,15 @@ declare(strict_types=1);
namespace App\Shared\Infrastructure\Export;
use App\Shared\Domain\Contract\SpreadsheetExporterInterface;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use RuntimeException;
use function is_string;
/**
* Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet.
*
@@ -31,19 +36,45 @@ final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface
$sheet->setTitle($this->sanitizeSheetTitle($sheetTitle));
// Ligne 1 : en-tete.
$sheet->fromArray($headers, null, 'A1');
$this->writeRow($sheet, $headers, 1);
// Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable
// paresseux (generator) sans tout materialiser en memoire.
$rowNumber = 2;
foreach ($rows as $row) {
$sheet->fromArray($row, null, 'A'.$rowNumber);
$this->writeRow($sheet, $row, $rowNumber);
++$rowNumber;
}
return $this->toBinary($spreadsheet);
}
/**
* Ecrit une ligne cellule par cellule. Toute valeur CHAINE est ecrite en type
* STRING explicite (jamais interpretee comme formule), ce qui neutralise
* l'injection de formules / DDE (« CSV / Formula injection ») : une cellule dont
* la valeur commence par `=` `+` `-` `@` (saisie utilisateur, ex. un numero) n'est
* pas evaluee a l'ouverture du fichier, et ce SANS apostrophe visible. Les valeurs
* non-chaines (int / float / null) gardent leur type naturel.
*
* @param list<null|scalar> $row
*/
private function writeRow(Worksheet $sheet, array $row, int $rowNumber): void
{
$column = 1;
foreach ($row as $value) {
$coordinate = Coordinate::stringFromColumnIndex($column).$rowNumber;
if (is_string($value)) {
$sheet->setCellValueExplicit($coordinate, $value, DataType::TYPE_STRING);
} else {
$sheet->setCellValue($coordinate, $value);
}
++$column;
}
}
private function toBinary(Spreadsheet $spreadsheet): string
{
$writer = new Xlsx($spreadsheet);
@@ -92,6 +92,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase
Assert\NotNull::class,
Assert\Email::class,
Assert\Choice::class,
Assert\Unique::class,
Assert\Regex::class,
Assert\Bic::class,
Assert\Iban::class,
@@ -138,6 +138,50 @@ final class StorageExportControllerTest extends AbstractStorageApiTestCase
self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[6]);
}
public function testFormulaInjectionIsNeutralized(): void
{
$client = $this->createAdminClient();
// Numero malicieux commencant par « = » (injection de formule / DDE). Seede en
// direct (le numero contournerait de toute facon le normalizer, qui ne fait
// qu'un trim). L'export doit le restituer comme TEXTE litteral, jamais comme
// une formule evaluee : si la cellule etait une formule, IOFactory::load la
// calculerait (resultat 3 ou erreur) et « =1+2 » serait absent de la colonne.
$this->seedStorageEntity('=1+2');
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent());
self::assertContains('=1+2', $numeros, 'Le numero « =1+2 » doit etre stocke en texte, pas evalue.');
}
public function testExportKeepsSearchTermZero(): void
{
$client = $this->createAdminClient();
$this->seedStorageEntity('0');
$this->seedStorageEntity('X1');
// « 0 » est un numero valide : le filtre ?search=0 NE DOIT PAS etre coerce a
// null (parite stricte avec la liste a l'ecran via StorageListFilters).
$numeros = $this->numeros($client->request('GET', self::EXPORT_URL.'?search=0')->getContent());
self::assertContains('0', $numeros);
self::assertNotContains('X1', $numeros);
}
public function testExportToleratesArrayShapedScalarParam(): void
{
$client = $this->createAdminClient();
$this->seedStorageEntity('NUM-ARR');
// ?search[]=foo : parametre tableau la ou un scalaire est attendu. L'export ne
// doit pas planter en 400 (la liste le tolere) : la valeur est simplement
// ignoree -> 200 avec tous les stockages.
$response = $client->request('GET', self::EXPORT_URL.'?search[]=foo');
self::assertResponseIsSuccessful();
self::assertContains('NUM-ARR', $this->numeros($response->getContent()));
}
public function testForbiddenWithoutStoragesViewPermission(): void
{
$creds = $this->createUserWithPermission('core.users.view');
@@ -80,6 +80,15 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase
self::assertArrayHasKey('displayName', $row);
self::assertSame('Cellule '.$numero, $row['displayName']);
// === Piege #5 : le soft-delete n'est JAMAIS expose (§ 2.8) ===
// `deletedAt` n'appartient a aucun groupe de lecture : un test « contrat » doit
// garantir son ABSENCE, pas seulement la presence des champs attendus — sinon
// un ajout accidentel a storage:read passerait au vert. (createdBy/updatedBy
// sont, eux, exposes a dessein via la convention `default:read` du Trait
// Timestampable/Blamable — au meme titre que createdAt/updatedAt.)
self::assertArrayNotHasKey('deletedAt', $row, 'deletedAt ne doit pas etre expose en liste (§ 2.8).');
self::assertArrayNotHasKey('deletedAt', $detail, 'deletedAt ne doit pas etre expose en detail (§ 2.8).');
// === DETAIL : memes garanties d'embarquement ===
self::assertIsArray($detail['site']);
self::assertArrayHasKey('name', $detail['site']);
@@ -93,10 +102,14 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase
/**
* RG-7.07 : la liste (et le detail) n'exposent JAMAIS un stockage soft-deleted.
* On seede 1 actif + 1 supprime pour que l'assertion de liste soit discriminante
* (sinon, avec une collection vide, « absent » ne distingue pas l'exclusion du
* soft-delete d'une page vide).
*/
public function testSoftDeletedIsNotExposed(): void
{
$deleted = $this->seedStorageEntity('SD', deletedAt: new DateTimeImmutable());
$active = $this->seedStorageEntity('SD-ACTIVE');
$deleted = $this->seedStorageEntity('SD-DELETED', deletedAt: new DateTimeImmutable());
$client = $this->createAdminClient();
@@ -104,9 +117,10 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase
$client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(404);
// et absent de la collection (RG-7.07).
// Collection : l'actif est present, le supprime est absent (RG-7.07).
$list = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]])->toArray();
self::assertNull($this->memberById($list, (int) $deleted->getId()));
self::assertNotNull($this->memberById($list, (int) $active->getId()), 'Le stockage actif doit etre liste.');
self::assertNull($this->memberById($list, (int) $deleted->getId()), 'Le stockage soft-deleted ne doit pas etre liste.');
}
/**
@@ -72,4 +72,40 @@ final class StorageStatesValidationTest extends AbstractStorageApiTestCase
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testDuplicateStatesAreRejected(): void
{
$client = $this->createAdminClient();
// Doublon dans le multi-select : 422 (Assert\Unique), pas un stockage avec un
// tableau d'etats incoherent (RG-7.04 = sous-ensemble).
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload([
'states' => [Storage::STATE_TRIAGE, Storage::STATE_TRIAGE],
]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('states', $this->violationPaths($response));
}
public function testNonSequentialStatesDoNotCrash(): void
{
$client = $this->createAdminClient();
// `states` envoye comme OBJET JSON (cle non sequentielle) : auparavant
// persiste tel quel en JSONB objet -> le CHECK jsonb_array_length plantait en
// 500. Doit desormais etre renormalise en liste sequentielle (array_values du
// setter), donc accepte proprement sans 500.
$created = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload([
'states' => [7 => Storage::STATE_RECEPTION],
]),
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame([Storage::STATE_RECEPTION], $created['states']);
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Catalog\Api;
/**
* Validation et normalisation serveur a l'ecriture du stockage (M7, POST / PATCH) :
* - RG-7.06 : le numero est trimme cote serveur (et SANS changement de casse) ;
* - numero vide -> 422 (Assert\NotBlank) sur `numero` ;
* - relation nulle (site / storageType) -> 422 (Assert\NotNull, via le chemin de
* denormalisation `collectDenormalizationErrors`) portant le bon propertyPath, et
* NON un 400 qui court-circuiterait le mapping inline front (useFormErrors,
* ERP-101).
*
* Pendant ces RG, le contrat de violation 422 (propertyPath aligne sur le champ
* front) est ce que le front consomme : on l'asserte explicitement.
*
* @internal
*/
final class StorageWriteValidationTest extends AbstractStorageApiTestCase
{
public function testNumeroIsTrimmedServerSide(): void
{
$client = $this->createAdminClient();
// RG-7.06 : numero saisi avec des espaces autour -> stocke trimme.
$created = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['numero' => ' A1 ']),
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('A1', $created['numero'], 'Le numero doit etre trimme cote serveur (RG-7.06).');
// Relecture : la normalisation est bien persistee, pas seulement reflechie.
$detail = $client->request('GET', '/api/storages/'.$created['id'], [
'headers' => ['Accept' => self::LD],
])->toArray();
self::assertSame('A1', $detail['numero']);
}
public function testBlankNumeroIsRejected(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['numero' => ' ']),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('numero', $this->violationPaths($response));
}
public function testNullSiteReturns422WithPropertyPath(): void
{
$client = $this->createAdminClient();
// Relation obligatoire a null : doit ressortir en 422 (NotNull) avec un
// propertyPath `site`, pas en 400 (collectDenormalizationErrors).
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['site' => null]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('site', $this->violationPaths($response));
}
public function testNullStorageTypeReturns422WithPropertyPath(): void
{
$client = $this->createAdminClient();
$response = $client->request('POST', '/api/storages', [
'headers' => ['Content-Type' => self::LD],
'json' => $this->validStoragePayload(['storageType' => null]),
]);
self::assertResponseStatusCodeSame(422);
self::assertContains('storageType', $this->violationPaths($response));
}
}