205 lines
8.5 KiB
PHP
205 lines
8.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Catalog\Api;
|
|
|
|
use App\Module\Catalog\Domain\Entity\Storage;
|
|
use DateTimeImmutable;
|
|
|
|
/**
|
|
* Tests fonctionnels de l'API stockage (M7, spec-back § 4) — StorageProvider +
|
|
* StorageProcessor (ERP-213).
|
|
*
|
|
* Couvre : collection paginee Hydra + contrat de serialisation (site / storageType
|
|
* embarques, displayName), creation, normalisation serveur du numero (trim, RG-7.06),
|
|
* unicite metier (site, type, numero) parmi les actifs -> 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.');
|
|
}
|
|
}
|
|
}
|