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.'); } } }