test(catalog) : M7 — tests RG-7.01→7.08 + contrat de sérialisation stockage (ERP-215)
This commit is contained in:
@@ -1,204 +0,0 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Storage;
|
||||
|
||||
/**
|
||||
* RG-7.05 : `displayName` (getter virtuel, non persiste) = « <label du type> <numero> ».
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* RBAC du stockage (M7, ERP-210 — admin-only). Jumeau du ProductRBACMatrixTest.
|
||||
*
|
||||
* La matrice est volontairement tres restrictive : seul l'Admin porte
|
||||
* `catalog.storages.view` / `.manage`. Les 4 personas metier MALIO (Bureau, Compta,
|
||||
* Commerciale, Usine) n'ont AUCUNE permission stockage -> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Storage;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Contrat de serialisation du stockage (M7, spec-back § 4.0 / § 4.0.bis).
|
||||
* Jumeau du ProductSerializationContractTest (M6).
|
||||
*
|
||||
* Capture le JSON REEL (liste + detail) via un stockage cree par l'API (POST reel,
|
||||
* normalisation serveur reelle) et reverifie les pieges du RETEX M1 transposes au
|
||||
* M7 :
|
||||
* #1 : `site` sort en OBJET embarque (site:read), jamais en IRI nu.
|
||||
* #2 : `storageType` sort en OBJET embarque (storage_type:read), jamais en IRI nu.
|
||||
* #3 : `states` = tableau de chaines.
|
||||
* #4 : `displayName` present et correct (RG-7.05 : « <label> <numero> »).
|
||||
*
|
||||
* 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<string, mixed> $list
|
||||
* @param array<string, mixed> $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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Storage;
|
||||
|
||||
/**
|
||||
* RG-7.04 : `states` = multi-select ⊆ {RECEPTION, PRODUCTION, TRIAGE}, au moins 1
|
||||
* requis. RG-7.08 : le PATCH applique les memes regles que le POST.
|
||||
*
|
||||
* Couvre :
|
||||
* - tableau d'etats vide -> 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
/**
|
||||
* RG-7.03 : « le type de stockage doit etre disponible sur le site choisi ».
|
||||
*
|
||||
* VOLONTAIREMENT NON IMPLEMENTEE (decision validee avec Tristan, ERP-213) : le
|
||||
* concept type<->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).',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Module\Catalog\Api;
|
||||
|
||||
use App\Module\Catalog\Domain\Entity\Storage;
|
||||
use App\Module\Catalog\Domain\Entity\StorageType;
|
||||
use App\Module\Sites\Domain\Entity\Site;
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function count;
|
||||
|
||||
/**
|
||||
* RG-7.01 : unicite metier du triplet (site, storageType, numero) parmi les ACTIFS.
|
||||
* RG-7.08 : le PATCH applique les memes regles que le POST.
|
||||
*
|
||||
* Couvre :
|
||||
* - 409 sur doublon de triplet actif (pre-check deterministe du Processor) ;
|
||||
* - meme numero accepte sur un AUTRE site, ou sur un AUTRE type (unicite portee
|
||||
* par le triplet complet, pas le seul numero) ;
|
||||
* - reutilisation possible d'un triplet porte par un stockage soft-deleted (l'index
|
||||
* partiel uq_storage_site_type_numero_active ne contraint que les actifs) ;
|
||||
* - PATCH d'un numero vers un triplet deja pris -> 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<string, mixed>
|
||||
*/
|
||||
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],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user