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/StorageApiTest.php b/tests/Module/Catalog/Api/StorageApiTest.php deleted file mode 100644 index fa03a72..0000000 --- a/tests/Module/Catalog/Api/StorageApiTest.php +++ /dev/null @@ -1,204 +0,0 @@ - 409 (RG-7.01), reutilisation - * du triplet apres soft-delete, soft-delete jamais expose (§ 2.8), et la matrice RBAC - * admin-only (view lit mais ne gere pas ; personas metier 403 partout). - * - * RG-7.03 (« type disponible sur le site choisi ») n'est PAS testee : le concept - * type<->site a ete retire du modele en M6 (StorageType plat), c'est Storage qui le - * porte desormais — aucun referentiel a interroger (cf. StorageProcessor). - * - * @internal - */ -final class StorageApiTest extends AbstractStorageApiTestCase -{ - /** Personas metier sans permission stockage (admin-only — ERP-210). */ - private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine']; - - public function testCollectionIsPaginatedHydraWithEmbeddedRelations(): void - { - $site = $this->firstSite(); - $type = $this->seedStorageType('Cellule'); - $seed = $this->seedStorageEntity('C3', site: $site, storageType: $type); - - $client = $this->createAdminClient(); - $response = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(200); - - $body = $response->toArray(); - // Enveloppe Hydra (regle ABSOLUE n°13 : collection paginee, jamais d'array brut). - self::assertArrayHasKey('totalItems', $body); - self::assertArrayHasKey('member', $body); - - $member = $this->memberById($body, (int) $seed->getId()); - self::assertIsArray($member, 'Le stockage seede doit figurer dans la collection.'); - - // Contrat de serialisation (§ 4.0.bis) : site / storageType en OBJETS embarques - // (pas un IRI nu), displayName present (RG-7.05). - self::assertIsArray($member['site'], 'site doit etre un objet embarque.'); - self::assertSame($site->getCode(), $member['site']['code'] ?? null); - self::assertIsArray($member['storageType'], 'storageType doit etre un objet embarque.'); - self::assertSame('Cellule', $member['storageType']['label'] ?? null); - self::assertSame('Cellule C3', $member['displayName'] ?? null); - } - - public function testAdminCanCreateStorage(): void - { - $client = $this->createAdminClient(); - - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validStoragePayload(), - ]); - self::assertResponseStatusCodeSame(201); - } - - public function testNumeroIsTrimmedServerSide(): void - { - $client = $this->createAdminClient(); - $response = $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validStoragePayload(['numero' => ' Z9 ']), - ]); - self::assertResponseStatusCodeSame(201); - - // RG-7.06 : trim serveur, sans changement de casse (HP-M7-05). - self::assertSame('Z9', $response->toArray()['numero'] ?? null); - } - - public function testDuplicateTripletReturns409(): void - { - $site = $this->firstSite(); - $type = $this->seedStorageType(); - $this->seedStorageEntity('A1', site: $site, storageType: $type); - - $client = $this->createAdminClient(); - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => [ - 'site' => $this->iri('sites', (int) $site->getId()), - 'storageType' => $this->iri('storage_types', (int) $type->getId()), - 'numero' => 'A1', - 'states' => [Storage::STATE_RECEPTION], - ], - ]); - // RG-7.01 : meme (site, type, numero) parmi les actifs -> 409. - self::assertResponseStatusCodeSame(409); - } - - public function testSameNumeroDifferentTypeIsAllowed(): void - { - $site = $this->firstSite(); - $typeA = $this->seedStorageType(); - $typeB = $this->seedStorageType(); - $this->seedStorageEntity('A1', site: $site, storageType: $typeA); - - $client = $this->createAdminClient(); - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => [ - 'site' => $this->iri('sites', (int) $site->getId()), - 'storageType' => $this->iri('storage_types', (int) $typeB->getId()), - 'numero' => 'A1', - 'states' => [Storage::STATE_RECEPTION], - ], - ]); - // Unicite portee par le TRIPLET : un meme numero sur un autre type passe. - self::assertResponseStatusCodeSame(201); - } - - public function testSoftDeletedTripletCanBeReused(): void - { - $site = $this->firstSite(); - $type = $this->seedStorageType(); - $this->seedStorageEntity('B2', deletedAt: new DateTimeImmutable(), site: $site, storageType: $type); - - $client = $this->createAdminClient(); - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => [ - 'site' => $this->iri('sites', (int) $site->getId()), - 'storageType' => $this->iri('storage_types', (int) $type->getId()), - 'numero' => 'B2', - 'states' => [Storage::STATE_RECEPTION], - ], - ]); - // RG-7.01 : l'unicite ne porte que sur les ACTIFS -> reutilisation OK. - self::assertResponseStatusCodeSame(201); - } - - public function testSoftDeletedIsNotExposed(): void - { - $deleted = $this->seedStorageEntity('SD', deletedAt: new DateTimeImmutable()); - - $client = $this->createAdminClient(); - - // § 2.8 : item soft-deleted -> 404. - $client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(404); - - // … et absent de la collection (RG-7.07). - $response = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); - self::assertNull($this->memberById($response->toArray(), (int) $deleted->getId())); - } - - public function testViewPermissionReadsButCannotManage(): void - { - $storage = $this->seedStorageEntity(); - $client = $this->authView(); - - $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(200); - - $client->request('GET', '/api/storages/'.$storage->getId(), ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(200); - - // view sans manage : creation refusee au niveau securite (403). - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validStoragePayload(), - ]); - self::assertResponseStatusCodeSame(403); - } - - public function testBusinessPersonasAreForbiddenEverywhere(): void - { - $storage = $this->seedStorageEntity(); - $id = (int) $storage->getId(); - - foreach (self::PERSONAS as $persona) { - $client = $this->createPersonaClient($persona); - - $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les stockages.'); - - $client->request('GET', '/api/storages/'.$id, ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un stockage.'); - - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validStoragePayload(), - ]); - self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de stockage.'); - - $client->request('PATCH', '/api/storages/'.$id, [ - 'headers' => ['Content-Type' => self::MERGE], - 'json' => ['numero' => 'X'], - ]); - self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un stockage.'); - } - } -} diff --git a/tests/Module/Catalog/Api/StorageDisplayNameTest.php b/tests/Module/Catalog/Api/StorageDisplayNameTest.php new file mode 100644 index 0000000..f59c81d --- /dev/null +++ b/tests/Module/Catalog/Api/StorageDisplayNameTest.php @@ -0,0 +1,39 @@ + ». + * + * On asserte sur le CORPS JSON reel renvoye par l'API (pas sur le getter PHP), pour + * figer le contrat consomme par le front. + * + * @internal + */ +final class StorageDisplayNameTest extends AbstractStorageApiTestCase +{ + public function testDisplayNameConcatenatesLabelAndNumero(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType('Boisseau'); + $numero = $this->uniqueCode('NUM'); + + $client = $this->createAdminClient(); + $created = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $type->getId()), + 'numero' => $numero, + 'states' => [Storage::STATE_RECEPTION], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Boisseau '.$numero, $created['displayName'] ?? null); + } +} 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/StorageRBACMatrixTest.php b/tests/Module/Catalog/Api/StorageRBACMatrixTest.php new file mode 100644 index 0000000..1fce380 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageRBACMatrixTest.php @@ -0,0 +1,90 @@ + 403 partout. Un porteur de + * `view` lit (200) mais ne peut pas creer (403). Anonyme -> 401. + * + * @internal + */ +final class StorageRBACMatrixTest extends AbstractStorageApiTestCase +{ + /** Personas metier sans permission stockage (admin-only). */ + private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine']; + + public function testAdminHasFullAccess(): void + { + $client = $this->createAdminClient(); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testBusinessPersonasAreForbiddenEverywhere(): void + { + $storage = $this->seedStorageEntity(); + $id = (int) $storage->getId(); + + foreach (self::PERSONAS as $persona) { + $client = $this->createPersonaClient($persona); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les stockages.'); + + $client->request('GET', '/api/storages/'.$id, ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un stockage.'); + + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de stockage.'); + + $client->request('PATCH', '/api/storages/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['numero' => 'X'], + ]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un stockage.'); + } + } + + public function testViewPermissionReadsButCannotManage(): void + { + $storage = $this->seedStorageEntity(); + $client = $this->authView(); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $client->request('GET', '/api/storages/'.$storage->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // view sans manage : creation refusee au niveau securite (403). + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testAnonymousIsUnauthorized(): void + { + $client = self::createClient(); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(401); + } +} diff --git a/tests/Module/Catalog/Api/StorageSerializationContractTest.php b/tests/Module/Catalog/Api/StorageSerializationContractTest.php new file mode 100644 index 0000000..da32595 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageSerializationContractTest.php @@ -0,0 +1,143 @@ + »). + * + * REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations. + * DoD (§ 4.0.bis) : avec STORAGE_DOD_DUMP positionnee, ecrit les corps liste + + * detail sous /tmp pour les coller dans la spec avant les ecrans front. + * + * @internal + */ +final class StorageSerializationContractTest extends AbstractStorageApiTestCase +{ + public function testListAndDetailSerializationContract(): void + { + $client = $this->createAdminClient(); + + $site = $this->firstSite(); + $type = $this->seedStorageType('Cellule'); + $numero = $this->uniqueCode('NUM'); + + // Stockage cree par un POST reel (2 etats pour exercer le tableau). + $created = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $type->getId()), + 'numero' => $numero, + 'states' => [Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + $id = (int) $created['id']; + + $detail = $client->request('GET', '/api/storages/'.$id, [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + $list = $client->request('GET', '/api/storages?search='.$numero, [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + + // Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:). + self::assertArrayHasKey('member', $list); + self::assertArrayNotHasKey('hydra:member', $list); + + $row = $this->memberById($list, $id); + self::assertNotNull($row, 'Le stockage cree doit apparaitre dans la liste filtree.'); + + // === Piege #1 : site en OBJET embarque (pas IRI nu) === + self::assertIsArray($row['site'], 'site doit etre un objet embarque (site:read), pas un IRI nu.'); + self::assertArrayHasKey('name', $row['site']); + self::assertArrayHasKey('code', $row['site']); + + // === Piege #2 : storageType en OBJET embarque (pas IRI nu) === + self::assertIsArray($row['storageType'], 'storageType doit etre un objet embarque (storage_type:read), pas un IRI nu.'); + self::assertArrayHasKey('label', $row['storageType']); + self::assertSame('Cellule', $row['storageType']['label']); + + // === Piege #3 : states tableau de chaines === + self::assertSame([Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], $row['states']); + + // === Piege #4 : displayName present + correct (RG-7.05) === + 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']); + self::assertIsArray($detail['storageType']); + self::assertArrayHasKey('label', $detail['storageType']); + self::assertSame([Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], $detail['states']); + self::assertSame('Cellule '.$numero, $detail['displayName']); + + $this->dumpDodIfRequested($list, $detail); + } + + /** + * 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 + { + $active = $this->seedStorageEntity('SD-ACTIVE'); + $deleted = $this->seedStorageEntity('SD-DELETED', deletedAt: new DateTimeImmutable()); + + $client = $this->createAdminClient(); + + // Item soft-deleted -> 404 (§ 2.8). + $client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(404); + + // Collection : l'actif est present, le supprime est absent (RG-7.07). + $list = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]])->toArray(); + 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.'); + } + + /** + * DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si STORAGE_DOD_DUMP est + * positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis. + * + * @param array $list + * @param array $detail + */ + private function dumpDodIfRequested(array $list, array $detail): void + { + if (false === getenv('STORAGE_DOD_DUMP')) { + return; + } + + $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + file_put_contents('/tmp/storage-dod-list.json', json_encode($list, $flags)); + file_put_contents('/tmp/storage-dod-detail.json', json_encode($detail, $flags)); + } +} diff --git a/tests/Module/Catalog/Api/StorageStatesValidationTest.php b/tests/Module/Catalog/Api/StorageStatesValidationTest.php new file mode 100644 index 0000000..a3c4fdb --- /dev/null +++ b/tests/Module/Catalog/Api/StorageStatesValidationTest.php @@ -0,0 +1,111 @@ + 422 (Assert\Count(min: 1)) sur le champ `states` ; + * - valeur hors enum -> 422 (Assert\Choice) sur le champ `states` ; + * - un seul etat valide -> 201 (borne basse acceptee) ; + * - PATCH vers un tableau d'etats vide -> 422 (RG-7.08). + * + * @internal + */ +final class StorageStatesValidationTest extends AbstractStorageApiTestCase +{ + public function testEmptyStatesIsRejected(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['states' => []]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('states', $this->violationPaths($response)); + } + + public function testUnknownStateValueIsRejected(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['states' => [Storage::STATE_RECEPTION, 'FOOBAR']]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('states', $this->violationPaths($response)); + } + + public function testSingleValidStateIsAccepted(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['states' => [Storage::STATE_PRODUCTION]]), + ]); + + self::assertResponseStatusCodeSame(201); + } + + public function testPatchToEmptyStatesIsRejected(): void + { + $storage = $this->seedStorageEntity(); + + // RG-7.08 : la regle RG-7.04 vaut aussi en edition. + $client = $this->createAdminClient(); + $response = $client->request('PATCH', '/api/storages/'.$storage->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['states' => []], + ]); + + 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/StorageTypeBySiteTest.php b/tests/Module/Catalog/Api/StorageTypeBySiteTest.php new file mode 100644 index 0000000..4abe93b --- /dev/null +++ b/tests/Module/Catalog/Api/StorageTypeBySiteTest.php @@ -0,0 +1,34 @@ +site a ete RETIRE du modele en M6. La jointure storage_type_site a + * ete droppee (migration Version20260626100000) et StorageType est devenu un + * referentiel PLAT, sans relation `sites` — l'entite le documente explicitement + * (« un type n'est PAS rattache a des sites ; la dispo releve de la future entite + * Stockage »). C'est desormais l'entite Storage (1 site + 1 type) qui MATERIALISE + * cette disponibilite ; il n'existe plus de referentiel a interroger pour lever une + * 422. RG-7.03 est donc inimplementable telle quelle. + * + * Ce test est conserve (skippe) pour la TRACABILITE DoD : il documente le gap dans + * la suite et devra etre reactive si la spec reintroduit un lien type<->site. + * + * @internal + */ +final class StorageTypeBySiteTest extends AbstractStorageApiTestCase +{ + public function testTypeUnavailableOnSiteIsRejected(): void + { + self::markTestSkipped( + 'RG-7.03 non portee : StorageType est un referentiel plat depuis le M6 ' + .'(jointure storage_type_site droppee). Aucun referentiel type<->site a ' + .'interroger. A reclarifier cote spec (cf. ERP-213).', + ); + } +} diff --git a/tests/Module/Catalog/Api/StorageUniquenessTest.php b/tests/Module/Catalog/Api/StorageUniquenessTest.php new file mode 100644 index 0000000..05e1415 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageUniquenessTest.php @@ -0,0 +1,121 @@ + 409 (RG-7.08). + * + * @internal + */ +final class StorageUniquenessTest extends AbstractStorageApiTestCase +{ + public function testDuplicateActiveTripletReturns409(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $site, storageType: $type); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->tripletPayload($site, $type, 'A1'), + ]); + self::assertResponseStatusCodeSame(409); + } + + public function testSameNumeroOnAnotherTypeIsAccepted(): void + { + $site = $this->firstSite(); + $typeA = $this->seedStorageType(); + $typeB = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $site, storageType: $typeA); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->tripletPayload($site, $typeB, 'A1'), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testSameNumeroOnAnotherSiteIsAccepted(): void + { + $sites = $this->getEm()->getRepository(Site::class)->findAll(); + if (count($sites) < 2) { + self::markTestSkipped('Au moins 2 sites fixtures requis pour ce cas.'); + } + + $type = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $sites[0], storageType: $type); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->tripletPayload($sites[1], $type, 'A1'), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testSoftDeletedTripletCanBeReused(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType(); + $this->seedStorageEntity('B2', deletedAt: new DateTimeImmutable(), site: $site, storageType: $type); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->tripletPayload($site, $type, 'B2'), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testPatchToExistingTripletReturns409(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $site, storageType: $type); + $target = $this->seedStorageEntity('B2', site: $site, storageType: $type); + + // RG-7.08 : PATCH du numero B2 -> A1 (meme site+type) collisionne -> 409. + $client = $this->createAdminClient(); + $client->request('PATCH', '/api/storages/'.$target->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['numero' => 'A1'], + ]); + self::assertResponseStatusCodeSame(409); + } + + /** + * Payload POST minimal pour un triplet (site, type, numero) donne. + * + * @return array + */ + private function tripletPayload(Site $site, StorageType $type, string $numero): array + { + return [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $type->getId()), + 'numero' => $numero, + 'states' => [Storage::STATE_RECEPTION], + ]; + } +} 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)); + } +}