diff --git a/src/Module/Catalog/Application/Filter/StorageListFilters.php b/src/Module/Catalog/Application/Filter/StorageListFilters.php new file mode 100644 index 0000000..c5a4396 --- /dev/null +++ b/src/Module/Catalog/Application/Filter/StorageListFilters.php @@ -0,0 +1,131 @@ + 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 $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 $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 + */ + 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; + } +} diff --git a/src/Module/Catalog/Domain/Entity/Storage.php b/src/Module/Catalog/Domain/Entity/Storage.php index 5eafb19..6933e47 100644 --- a/src/Module/Catalog/Domain/Entity/Storage.php +++ b/src/Module/Catalog/Domain/Entity/Storage.php @@ -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; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php index 189098f..9b8fe50 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php @@ -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 - */ - 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; - } } diff --git a/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php b/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php index e45e363..e116dca 100644 --- a/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php +++ b/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php @@ -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 $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 $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 $storages * * @return iterable> */ - 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 - */ - 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; - } } diff --git a/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php b/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php index b479cb1..079c9a1 100644 --- a/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php +++ b/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php @@ -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 $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); diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 876aa48..dffa8ec 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -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, diff --git a/tests/Module/Catalog/Api/StorageExportControllerTest.php b/tests/Module/Catalog/Api/StorageExportControllerTest.php index a161a55..828a82c 100644 --- a/tests/Module/Catalog/Api/StorageExportControllerTest.php +++ b/tests/Module/Catalog/Api/StorageExportControllerTest.php @@ -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'); diff --git a/tests/Module/Catalog/Api/StorageSerializationContractTest.php b/tests/Module/Catalog/Api/StorageSerializationContractTest.php index 6a7e323..da32595 100644 --- a/tests/Module/Catalog/Api/StorageSerializationContractTest.php +++ b/tests/Module/Catalog/Api/StorageSerializationContractTest.php @@ -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.'); } /** diff --git a/tests/Module/Catalog/Api/StorageStatesValidationTest.php b/tests/Module/Catalog/Api/StorageStatesValidationTest.php index 12fa054..a3c4fdb 100644 --- a/tests/Module/Catalog/Api/StorageStatesValidationTest.php +++ b/tests/Module/Catalog/Api/StorageStatesValidationTest.php @@ -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']); + } } diff --git a/tests/Module/Catalog/Api/StorageWriteValidationTest.php b/tests/Module/Catalog/Api/StorageWriteValidationTest.php new file mode 100644 index 0000000..efcd50f --- /dev/null +++ b/tests/Module/Catalog/Api/StorageWriteValidationTest.php @@ -0,0 +1,83 @@ + 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)); + } +}