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/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..6a7e323 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageSerializationContractTest.php @@ -0,0 +1,129 @@ + »). + * + * 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']); + + // === 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. + */ + public function testSoftDeletedIsNotExposed(): void + { + $deleted = $this->seedStorageEntity('SD', 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); + + // … et absent de la collection (RG-7.07). + $list = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertNull($this->memberById($list, (int) $deleted->getId())); + } + + /** + * 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..12fa054 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageStatesValidationTest.php @@ -0,0 +1,75 @@ + 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)); + } +} 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], + ]; + } +}