»). * * 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']); // === Piege #5 : le soft-delete n'est JAMAIS expose (§ 2.8) === // `deletedAt` n'appartient a aucun groupe de lecture : un test « contrat » doit // garantir son ABSENCE, pas seulement la presence des champs attendus — sinon // un ajout accidentel a storage:read passerait au vert. (createdBy/updatedBy // sont, eux, exposes a dessein via la convention `default:read` du Trait // Timestampable/Blamable — au meme titre que createdAt/updatedAt.) self::assertArrayNotHasKey('deletedAt', $row, 'deletedAt ne doit pas etre expose en liste (§ 2.8).'); self::assertArrayNotHasKey('deletedAt', $detail, 'deletedAt ne doit pas etre expose en detail (§ 2.8).'); // === 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. * On seede 1 actif + 1 supprime pour que l'assertion de liste soit discriminante * (sinon, avec une collection vide, « absent » ne distingue pas l'exclusion du * soft-delete d'une page vide). */ public function testSoftDeletedIsNotExposed(): void { $active = $this->seedStorageEntity('SD-ACTIVE'); $deleted = $this->seedStorageEntity('SD-DELETED', 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); // Collection : l'actif est present, le supprime est absent (RG-7.07). $list = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]])->toArray(); self::assertNotNull($this->memberById($list, (int) $active->getId()), 'Le stockage actif doit etre liste.'); self::assertNull($this->memberById($list, (int) $deleted->getId()), 'Le stockage soft-deleted ne doit pas etre liste.'); } /** * 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)); } }