From 0aa97b5975a3cbd019445579bacc8f0973df257f Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 16:43:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(catalog)=20:=20M7=20=E2=80=94=20StoragePro?= =?UTF-8?q?vider=20+=20StorageProcessor=20(liste=20pagin=C3=A9e=20+=20filt?= =?UTF-8?q?res,=20409=20unicit=C3=A9=20RG-7.01,=20normalisation=20num?= =?UTF-8?q?=C3=A9ro)=20(ERP-213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/StorageFieldNormalizer.php | 35 +++ .../Repository/StorageRepositoryInterface.php | 34 +++ .../State/Processor/StorageProcessor.php | 112 ++++++++++ .../State/Provider/StorageProvider.php | 172 +++++++++++++++ .../Doctrine/DoctrineStorageRepository.php | 107 +++++++++ .../Api/AbstractStorageApiTestCase.php | 202 +++++++++++++++++ tests/Module/Catalog/Api/StorageApiTest.php | 204 ++++++++++++++++++ 7 files changed, 866 insertions(+) create mode 100644 src/Module/Catalog/Application/Service/StorageFieldNormalizer.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/StorageProcessor.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php create mode 100644 tests/Module/Catalog/Api/AbstractStorageApiTestCase.php create mode 100644 tests/Module/Catalog/Api/StorageApiTest.php diff --git a/src/Module/Catalog/Application/Service/StorageFieldNormalizer.php b/src/Module/Catalog/Application/Service/StorageFieldNormalizer.php new file mode 100644 index 0000000..492e1f7 --- /dev/null +++ b/src/Module/Catalog/Application/Service/StorageFieldNormalizer.php @@ -0,0 +1,35 @@ + $siteIds + */ + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $search = null, + array $siteIds = [], + ?int $storageTypeId = null, + ?string $state = null, + ): QueryBuilder; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/StorageProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/StorageProcessor.php new file mode 100644 index 0000000..4558ab4 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/StorageProcessor.php @@ -0,0 +1,112 @@ + 409 ; + * l'index partiel uq_storage_site_type_numero_active reste le filet anti-race + * au flush. + * 3. Persistance via le persist_processor Doctrine ORM. + * + * RG-7.03 (« le type doit etre disponible sur le site choisi ») n'est PAS portee : + * le concept type<->site a ete retire du modele en M6 (StorageType rendu plat, + * jointure storage_type_site droppee — migration Version20260626100000). C'est + * desormais l'entite Storage (1 site + 1 type) qui materialise cette disponibilite ; + * il n'existe plus de referentiel a interroger. A reclarifier cote spec (signale). + * + * Mode strict PATCH (RETEX M1) : la security d'operation exige `catalog.storages.manage` + * pour TOUS les champs ecrivables (un seul niveau de permission au M7 — admin-only). + * Aucun champ « hors-permission » a re-gater finement ici : le 403 global est porte + * par la security d'operation. + * + * @implements ProcessorInterface + */ +final class StorageProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly StorageFieldNormalizer $normalizer, + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')] + private readonly StorageRepositoryInterface $repository, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Storage) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + // 1. RG-7.06 : normalisation serveur (numero trim, pas d'UPPER). + $this->normalize($data); + + // 2. RG-7.01 : unicite metier (site, storageType, numero) parmi les actifs + // (exclut le stockage courant en PATCH). Pre-check explicite -> 409 + // deterministe. Le NotNull site/type + NotBlank numero ont deja joue. + $siteId = $data->getSite()?->getId(); + $typeId = $data->getStorageType()?->getId(); + $numero = (string) $data->getNumero(); + if (null !== $siteId && null !== $typeId && '' !== $numero + && $this->repository->existsActiveBySiteTypeNumero($siteId, $typeId, $numero, $data->getId())) { + throw $this->duplicateConflict($numero); + } + + // 3. Persistance, avec filet anti-race sur l'index partiel. + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Insertion concurrente du meme triplet entre le pre-check et le flush + // (collision sur uq_storage_site_type_numero_active). + throw $this->duplicateConflict($numero, $e); + } + } + + /** + * Normalisation serveur du stockage (RG-7.06). Le setter n'est touche que si une + * valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. + * Le cast (string) est sur : NotBlank a deja rejete le vide en amont. + */ + private function normalize(Storage $data): void + { + if (null !== $data->getNumero()) { + $data->setNumero((string) $this->normalizer->normalizeNumero($data->getNumero())); + } + } + + /** + * RG-7.01 : 409 sur doublon (site, type, numero). Le front mappe ce conflit sur + * le champ `numero` (setError('numero', ...) + toast — convention useFormErrors + * ERP-101) : le propertyPath exploitable est `numero`. + */ + private function duplicateConflict(string $numero, ?Throwable $previous = null): ConflictHttpException + { + return new ConflictHttpException( + sprintf('Un stockage portant le numéro « %s » existe déjà pour ce site et ce type de stockage.', $numero), + $previous, + ); + } +} diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php new file mode 100644 index 0000000..189098f --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php @@ -0,0 +1,172 @@ + + */ +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, + private readonly Pagination $pagination, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Storage|null + { + if ($operation instanceof CollectionOperationInterface) { + // 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), + ); + + // Echappatoire ?pagination=false : collection complete sans Paginator. + if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); + } + + // Branche paginee standard : offset/limit via Pagination, enveloppe dans le + // Paginator ORM. Les jointures site/storageType sont to-ONE (ManyToOne) : + // pas de duplication de lignes, le comptage reste exact. + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + return new Paginator(new DoctrinePaginator($qb->getQuery())); + } + + // Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete. + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $storage = $this->repository->findById((int) $id); + if (null === $storage) { + return null; + } + + // § 2.8 : un stockage soft-deleted n'est jamais expose (404). + if (null !== $storage->getDeletedAt()) { + return null; + } + + 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/Doctrine/DoctrineStorageRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageRepository.php index e876314..216e017 100644 --- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageRepository.php +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageRepository.php @@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\Doctrine; use App\Module\Catalog\Domain\Entity\Storage; use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -29,4 +30,110 @@ class DoctrineStorageRepository extends ServiceEntityRepository implements Stora $this->getEntityManager()->persist($storage); $this->getEntityManager()->flush(); } + + public function existsActiveBySiteTypeNumero( + int $siteId, + int $storageTypeId, + string $numero, + ?int $excludeId = null, + ): bool { + $qb = $this->createQueryBuilder('s') + ->select('1') + ->andWhere('s.site = :siteId') + ->andWhere('s.storageType = :storageTypeId') + ->andWhere('s.numero = :numero') + ->andWhere('s.deletedAt IS NULL') + ->setParameter('siteId', $siteId) + ->setParameter('storageTypeId', $storageTypeId) + ->setParameter('numero', $numero) + ->setMaxResults(1) + ; + + if (null !== $excludeId) { + $qb->andWhere('s.id != :excludeId')->setParameter('excludeId', $excludeId); + } + + return [] !== $qb->getQuery()->getResult(); + } + + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $search = null, + array $siteIds = [], + ?int $storageTypeId = null, + ?string $state = null, + ): QueryBuilder { + // Eager-load des relations embarquees en liste (storage:read) pour eviter un + // N+1 par stockage : site et storageType sont des ManyToOne (to-ONE, sures — + // pas de duplication de lignes, contrairement aux ManyToMany du Product). Les + // jointures servent aussi le tri (site.code, storageType.label). + $qb = $this->createQueryBuilder('s') + ->leftJoin('s.site', 'site')->addSelect('site') + ->leftJoin('s.storageType', 'st')->addSelect('st') + ->orderBy('site.code', 'ASC') + ->addOrderBy('st.label', 'ASC') + ->addOrderBy('s.numero', 'ASC') + ; + + // RG-7.07 : la liste exclut par defaut les stockages soft-deleted. + if (!$includeDeleted) { + $qb->andWhere('s.deletedAt IS NULL'); + } + + // ?search= : recherche partielle case-insensitive sur numero. Les + // metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux. + if (null !== $search && '' !== trim($search)) { + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + $qb->andWhere('LOWER(s.numero) LIKE :search')->setParameter('search', $pattern); + } + + // ?siteId[]= : stockage rattache a AU MOINS UN des sites passes (OR). site est + // un ManyToOne (to-one) -> filtre direct sur la jointure, sans sous-requete + // EXISTS ni risque de masquer une collection (≠ Product.sites M2M). + if ([] !== $siteIds) { + $qb->andWhere('site.id IN (:siteIds)')->setParameter('siteIds', $siteIds); + } + + // ?storageTypeId= : filtre par type de stockage precis (id). + if (null !== $storageTypeId) { + $qb->andWhere('st.id = :storageTypeId')->setParameter('storageTypeId', $storageTypeId); + } + + // ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas exprimer + // la containment jsonb -> on resout les ids matchant en SQL natif (operateur + // @>), puis on contraint le QueryBuilder. Ids vides -> condition toujours + // fausse (aucun stockage), sans casser le reste de la requete. + if (null !== $state) { + $stateIds = $this->matchingStateIds($state); + if ([] === $stateIds) { + $qb->andWhere('1 = 0'); + } else { + $qb->andWhere('s.id IN (:stateIds)')->setParameter('stateIds', $stateIds); + } + } + + return $qb; + } + + /** + * Ids des stockages dont la colonne JSONB `states` contient l'etat donne, via + * l'operateur de containment Postgres `@>`. L'etat est borne a l'enum + * {RECEPTION, PRODUCTION, TRIAGE} en amont (StorageProvider) — pas de saisie + * libre ici. + * + * @return list + */ + private function matchingStateIds(string $state): array + { + $rows = $this->getEntityManager()->getConnection() + ->executeQuery( + 'SELECT id FROM storage WHERE states @> CAST(:state AS JSONB)', + ['state' => (string) json_encode([$state])], + ) + ->fetchFirstColumn() + ; + + return array_map(static fn (mixed $id): int => (int) $id, $rows); + } } diff --git a/tests/Module/Catalog/Api/AbstractStorageApiTestCase.php b/tests/Module/Catalog/Api/AbstractStorageApiTestCase.php new file mode 100644 index 0000000..404edb8 --- /dev/null +++ b/tests/Module/Catalog/Api/AbstractStorageApiTestCase.php @@ -0,0 +1,202 @@ +getEm(); + + // Stockages d'abord : ils referencent site / storage_type en FK RESTRICT. + $em->createQuery('DELETE FROM '.Storage::class)->execute(); + + // Types de stockage de test (prefixe code). + $em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix') + ->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%') + ->execute() + ; + + parent::tearDown(); + } + + /** + * Cree un type de stockage de test (code prefixe TESTSTO pour le cleanup). + */ + protected function seedStorageType(string $label = 'Cellule test'): StorageType + { + $em = $this->getEm(); + + $storageType = new StorageType(); + $storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX)); + $storageType->setLabel($label); + + $em->persist($storageType); + $em->flush(); + + return $storageType; + } + + protected function siteByCode(string $code): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]); + self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code)); + + return $site; + } + + protected function firstSite(): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).'); + + return $site; + } + + /** + * Client non-admin portant seulement `catalog.storages.view`. + */ + protected function authView(): Client + { + $creds = $this->createUserWithPermission('catalog.storages.view'); + + return $this->authenticatedClient($creds['username'], $creds['password']); + } + + /** + * Payload POST de reference : un stockage valide (1 site, 1 type, 1 numero, + * 1 etat). Surchargeable par cle via $overrides (ex: ['numero' => 'A1']). + * + * @param array $overrides + * + * @return array + */ + protected function validStoragePayload(array $overrides = []): array + { + $site = $this->firstSite(); + $storageType = $this->seedStorageType(); + + $base = [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $storageType->getId()), + 'numero' => $this->uniqueCode('NUM'), + 'states' => [Storage::STATE_RECEPTION], + ]; + + return array_replace($base, $overrides); + } + + /** + * Seede un stockage directement via l'EM (bypass Processor/Validator). Utile pour + * disposer d'un id existant (RBAC item, PATCH) ou d'un stockage soft-deleted + * (reutilisation du triplet — RG-7.01). Le site / le type manquants sont crees + * a la volee. + * + * @param list $states + */ + protected function seedStorageEntity( + ?string $numero = null, + array $states = [Storage::STATE_RECEPTION], + ?DateTimeImmutable $deletedAt = null, + ?Site $site = null, + ?StorageType $storageType = null, + ): Storage { + $em = $this->getEm(); + $site ??= $this->firstSite(); + + $storage = new Storage(); + $storage->setSite($em->getReference(Site::class, (int) $site->getId())); + $storage->setStorageType($storageType ?? $this->seedStorageType('Seed')); + $storage->setNumero($numero ?? $this->uniqueCode('NUM')); + $storage->setStates($states); + $storage->setDeletedAt($deletedAt); + + $em->persist($storage); + $em->flush(); + + return $storage; + } + + /** + * Construit un IRI API Platform (`/api/{resource}/{id}`). + */ + protected function iri(string $resource, int $id): string + { + return sprintf('/api/%s/%d', $resource, $id); + } + + /** + * Identifiant unique de test (prefixe + nonce), deja en MAJUSCULE. + */ + protected function uniqueCode(string $prefix): string + { + return $prefix.'_'.strtoupper(substr(bin2hex(random_bytes(5)), 0, 10)); + } + + /** + * Extrait les `propertyPath` des violations d'une reponse 422. + * + * @return list + */ + protected function violationPaths(ResponseInterface $response): array + { + $body = $response->toArray(false); + + return array_values(array_map( + static fn (array $violation): string => (string) ($violation['propertyPath'] ?? ''), + $body['violations'] ?? [], + )); + } + + /** + * Retrouve un membre d'une collection Hydra par son id (ou null). + * + * @param array $list + * + * @return null|array + */ + protected function memberById(array $list, int $id): ?array + { + foreach ($list['member'] ?? [] as $member) { + if (($member['id'] ?? null) === $id) { + return $member; + } + } + + return null; + } +} diff --git a/tests/Module/Catalog/Api/StorageApiTest.php b/tests/Module/Catalog/Api/StorageApiTest.php new file mode 100644 index 0000000..fa03a72 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageApiTest.php @@ -0,0 +1,204 @@ + 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.'); + } + } +}