From 55bed90ac7ab01cffbd32075086558715b5c1918 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Mar 2026 08:25:00 +0100 Subject: [PATCH] test(normalization) : add tests for skeleton requirements, composant slots, piece-product relation Co-Authored-By: Claude Opus 4.6 --- tests/AbstractApiTestCase.php | 80 ++++++++++++++ tests/Api/Entity/ComposantTest.php | 79 ++++++++++++++ tests/Api/Entity/MachineTest.php | 170 +++++++++++++++++++++++++++++ tests/Api/Entity/ModelTypeTest.php | 128 ++++++++++++++++++++++ tests/Api/Entity/PieceTest.php | 45 ++++++++ 5 files changed, 502 insertions(+) diff --git a/tests/AbstractApiTestCase.php b/tests/AbstractApiTestCase.php index ec2b8ae..df861bd 100644 --- a/tests/AbstractApiTestCase.php +++ b/tests/AbstractApiTestCase.php @@ -7,6 +7,9 @@ namespace App\Tests; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Symfony\Bundle\Test\Client; use App\Entity\Composant; +use App\Entity\ComposantPieceSlot; +use App\Entity\ComposantProductSlot; +use App\Entity\ComposantSubcomponentSlot; use App\Entity\Constructeur; use App\Entity\CustomField; use App\Entity\CustomFieldValue; @@ -314,6 +317,83 @@ abstract class AbstractApiTestCase extends ApiTestCase return $link; } + protected function createComposantPieceSlot( + Composant $composant, + ?ModelType $typePiece = null, + ?Piece $selectedPiece = null, + int $quantity = 1, + int $position = 0, + ): ComposantPieceSlot { + $slot = new ComposantPieceSlot(); + $slot->setComposant($composant); + $slot->setQuantity($quantity); + $slot->setPosition($position); + if (null !== $typePiece) { + $slot->setTypePiece($typePiece); + } + if (null !== $selectedPiece) { + $slot->setSelectedPiece($selectedPiece); + } + + $em = $this->getEntityManager(); + $em->persist($slot); + $em->flush(); + + return $slot; + } + + protected function createComposantSubcomponentSlot( + Composant $composant, + ?string $alias = null, + ?string $familyCode = null, + ?ModelType $typeComposant = null, + ?Composant $selectedComposant = null, + int $position = 0, + ): ComposantSubcomponentSlot { + $slot = new ComposantSubcomponentSlot(); + $slot->setComposant($composant); + $slot->setAlias($alias); + $slot->setFamilyCode($familyCode); + $slot->setPosition($position); + if (null !== $typeComposant) { + $slot->setTypeComposant($typeComposant); + } + if (null !== $selectedComposant) { + $slot->setSelectedComposant($selectedComposant); + } + + $em = $this->getEntityManager(); + $em->persist($slot); + $em->flush(); + + return $slot; + } + + protected function createComposantProductSlot( + Composant $composant, + ?ModelType $typeProduct = null, + ?Product $selectedProduct = null, + ?string $familyCode = null, + int $position = 0, + ): ComposantProductSlot { + $slot = new ComposantProductSlot(); + $slot->setComposant($composant); + $slot->setFamilyCode($familyCode); + $slot->setPosition($position); + if (null !== $typeProduct) { + $slot->setTypeProduct($typeProduct); + } + if (null !== $selectedProduct) { + $slot->setSelectedProduct($selectedProduct); + } + + $em = $this->getEntityManager(); + $em->persist($slot); + $em->flush(); + + return $slot; + } + // ── Assertion helpers ─────────────────────────────────────────── protected function assertJsonContainsHydraCollection(): void diff --git a/tests/Api/Entity/ComposantTest.php b/tests/Api/Entity/ComposantTest.php index 3d9fb45..ee9ae9f 100644 --- a/tests/Api/Entity/ComposantTest.php +++ b/tests/Api/Entity/ComposantTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\Api\Entity; +use App\Entity\Composant; use App\Enum\ModelCategory; use App\Tests\AbstractApiTestCase; @@ -135,4 +136,82 @@ class ComposantTest extends AbstractApiTestCase $this->assertResponseIsSuccessful(); $this->assertJsonContains(['totalItems' => 1]); } + + public function testComposantPieceSlotsPersistedAndReadable(): void + { + $pieceType = $this->createModelType('Joint', 'JOINT-CSLOT', ModelCategory::PIECE); + $piece = $this->createPiece('Joint slot', 'REF-CSLOT', $pieceType); + $composant = $this->createComposant('Composant slots pièces'); + + $this->createComposantPieceSlot($composant, $pieceType, $piece, 3, 0); + + $em = $this->getEntityManager(); + $em->clear(); + + $refetched = $em->find(Composant::class, $composant->getId()); + $this->assertNotNull($refetched); + $this->assertCount(1, $refetched->getPieceSlots()); + + $slot = $refetched->getPieceSlots()->first(); + $this->assertSame($pieceType->getId(), $slot->getTypePiece()->getId()); + $this->assertSame($piece->getId(), $slot->getSelectedPiece()->getId()); + $this->assertSame(3, $slot->getQuantity()); + $this->assertSame(0, $slot->getPosition()); + } + + public function testComposantSubcomponentSlotsPersistedAndReadable(): void + { + $subType = $this->createModelType('Sous-pompe', 'SP-001', ModelCategory::COMPONENT); + $subComposant = $this->createComposant('Sous-composant'); + $composant = $this->createComposant('Composant parent'); + + $this->createComposantSubcomponentSlot($composant, 'Sous-pompe A', 'SP-FAM', $subType, $subComposant, 0); + + $em = $this->getEntityManager(); + $em->clear(); + + $refetched = $em->find(Composant::class, $composant->getId()); + $this->assertNotNull($refetched); + $this->assertCount(1, $refetched->getSubcomponentSlots()); + + $slot = $refetched->getSubcomponentSlots()->first(); + $this->assertSame('Sous-pompe A', $slot->getAlias()); + $this->assertSame('SP-FAM', $slot->getFamilyCode()); + $this->assertSame($subType->getId(), $slot->getTypeComposant()->getId()); + $this->assertSame($subComposant->getId(), $slot->getSelectedComposant()->getId()); + } + + public function testComposantProductSlotsPersistedAndReadable(): void + { + $productType = $this->createModelType('Huile', 'HUILE-CSLOT', ModelCategory::PRODUCT); + $product = $this->createProduct('Huile slot', 'REF-HSLOT', $productType); + $composant = $this->createComposant('Composant slots produits'); + + $this->createComposantProductSlot($composant, $productType, $product, 'LUB', 0); + + $em = $this->getEntityManager(); + $em->clear(); + + $refetched = $em->find(Composant::class, $composant->getId()); + $this->assertNotNull($refetched); + $this->assertCount(1, $refetched->getProductSlots()); + + $slot = $refetched->getProductSlots()->first(); + $this->assertSame($productType->getId(), $slot->getTypeProduct()->getId()); + $this->assertSame($product->getId(), $slot->getSelectedProduct()->getId()); + $this->assertSame('LUB', $slot->getFamilyCode()); + } + + public function testComposantDeleteCascadesSlots(): void + { + $composant = $this->createComposant('Composant cascade'); + $this->createComposantPieceSlot($composant); + $this->createComposantProductSlot($composant); + $this->createComposantSubcomponentSlot($composant, 'Sub', 'FAM'); + + $client = $this->createGestionnaireClient(); + $client->request('DELETE', self::iri('composants', $composant->getId())); + + $this->assertResponseStatusCodeSame(204); + } } diff --git a/tests/Api/Entity/MachineTest.php b/tests/Api/Entity/MachineTest.php index 1066779..579c68a 100644 --- a/tests/Api/Entity/MachineTest.php +++ b/tests/Api/Entity/MachineTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\Api\Entity; +use App\Enum\ModelCategory; use App\Tests\AbstractApiTestCase; /** @@ -132,4 +133,173 @@ class MachineTest extends AbstractApiTestCase $this->assertResponseStatusCodeSame(422); } + + public function testGetStructureEndpoint(): void + { + $machine = $this->createMachine('Machine structure'); + $composant = $this->createComposant('Composant A'); + $piece = $this->createPiece('Pièce A', 'REF-PA'); + $product = $this->createProduct('Produit A', 'REF-PRA'); + + $compLink = $this->createMachineComponentLink($machine, $composant); + $this->createMachinePieceLink($machine, $piece, $compLink, 3); + $this->createMachineProductLink($machine, $product); + + $client = $this->createViewerClient(); + $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + $this->assertArrayHasKey('machine', $data); + $this->assertSame($machine->getId(), $data['machine']['id']); + + $this->assertArrayHasKey('componentLinks', $data); + $this->assertCount(1, $data['componentLinks']); + $this->assertSame($composant->getId(), $data['componentLinks'][0]['composantId']); + + $this->assertArrayHasKey('pieceLinks', $data); + $this->assertCount(1, $data['pieceLinks']); + $this->assertSame($piece->getId(), $data['pieceLinks'][0]['pieceId']); + + $this->assertArrayHasKey('productLinks', $data); + $this->assertCount(1, $data['productLinks']); + $this->assertSame($product->getId(), $data['productLinks'][0]['productId']); + } + + public function testGetStructureReturnsComposantSlotsData(): void + { + $pieceType = $this->createModelType('Joint', 'JOINT-SLOT', ModelCategory::PIECE); + $productType = $this->createModelType('Huile', 'HUILE-SLOT', ModelCategory::PRODUCT); + $compType = $this->createModelType('Pompe', 'POMPE-SLOT', ModelCategory::COMPONENT); + + $composant = $this->createComposant('Composant avec slots', $compType); + $piece = $this->createPiece('Joint sélectionné', 'REF-JS', $pieceType); + $product = $this->createProduct('Huile sélectionnée', 'REF-HS', $productType); + + // Create slots on the composant + $this->createComposantPieceSlot($composant, $pieceType, $piece, 2, 0); + $this->createComposantProductSlot($composant, $productType, $product, 'LUB', 0); + $this->createComposantSubcomponentSlot($composant, 'Sous-pompe', 'SP', $compType, null, 0); + + $machine = $this->createMachine('Machine slots'); + $this->createMachineComponentLink($machine, $composant); + + $client = $this->createViewerClient(); + $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + $compData = $data['componentLinks'][0]['composant']; + $this->assertArrayHasKey('structure', $compData); + + $structure = $compData['structure']; + + // Piece slots + $this->assertCount(1, $structure['pieces']); + $this->assertSame($pieceType->getId(), $structure['pieces'][0]['typePieceId']); + $this->assertSame(2, $structure['pieces'][0]['quantity']); + $this->assertSame($piece->getId(), $structure['pieces'][0]['selectedPieceId']); + $this->assertArrayHasKey('resolvedPiece', $structure['pieces'][0]); + $this->assertSame($piece->getId(), $structure['pieces'][0]['resolvedPiece']['id']); + + // Product slots + $this->assertCount(1, $structure['products']); + $this->assertSame($productType->getId(), $structure['products'][0]['typeProductId']); + $this->assertSame('LUB', $structure['products'][0]['familyCode']); + $this->assertSame($product->getId(), $structure['products'][0]['selectedProductId']); + + // Subcomponent slots + $this->assertCount(1, $structure['subcomponents']); + $this->assertSame('Sous-pompe', $structure['subcomponents'][0]['alias']); + $this->assertSame('SP', $structure['subcomponents'][0]['familyCode']); + $this->assertSame($compType->getId(), $structure['subcomponents'][0]['typeComposantId']); + } + + public function testGetStructureUnauthenticated(): void + { + $machine = $this->createMachine('Machine auth'); + + $client = $this->createUnauthenticatedClient(); + $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); + + $this->assertResponseStatusCodeSame(401); + } + + public function testGetStructureNotFound(): void + { + $client = $this->createViewerClient(); + $client->request('GET', '/api/machines/nonexistent-id/structure'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testPatchStructureEndpoint(): void + { + $machine = $this->createMachine('Machine PATCH'); + $composant = $this->createComposant('Composant PATCH'); + $piece = $this->createPiece('Pièce PATCH', 'REF-PATCH'); + + $client = $this->createGestionnaireClient(); + $client->request('PATCH', '/api/machines/'.$machine->getId().'/structure', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'componentLinks' => [ + ['composantId' => $composant->getId()], + ], + 'pieceLinks' => [ + ['pieceId' => $piece->getId(), 'parentComponentLinkId' => null, 'quantity' => 5], + ], + 'productLinks' => [], + ], + ]); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + $this->assertCount(1, $data['componentLinks']); + $this->assertSame($composant->getId(), $data['componentLinks'][0]['composantId']); + + $this->assertCount(1, $data['pieceLinks']); + $this->assertSame($piece->getId(), $data['pieceLinks'][0]['pieceId']); + $this->assertSame(5, $data['pieceLinks'][0]['quantity']); + } + + public function testPatchStructureViewerForbidden(): void + { + $machine = $this->createMachine('Machine PATCH forbidden'); + + $client = $this->createViewerClient(); + $client->request('PATCH', '/api/machines/'.$machine->getId().'/structure', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['componentLinks' => [], 'pieceLinks' => [], 'productLinks' => []], + ]); + + $this->assertResponseStatusCodeSame(403); + } + + public function testPieceQuantityFromComposantSlot(): void + { + $pieceType = $this->createModelType('Joint', 'JOINT-QTY', ModelCategory::PIECE); + $composant = $this->createComposant('Composant qty'); + $piece = $this->createPiece('Pièce qty', 'REF-QTY', $pieceType); + + // Create a piece slot with quantity 4 on the composant + $this->createComposantPieceSlot($composant, $pieceType, $piece, 4, 0); + + $machine = $this->createMachine('Machine qty'); + $compLink = $this->createMachineComponentLink($machine, $composant); + $this->createMachinePieceLink($machine, $piece, $compLink, 1); + + $client = $this->createViewerClient(); + $client->request('GET', '/api/machines/'.$machine->getId().'/structure'); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + // The quantity should come from the composant slot (4), not the link (1) + $pieceLinkData = $data['pieceLinks'][0]; + $this->assertSame(4, $pieceLinkData['quantity']); + } } diff --git a/tests/Api/Entity/ModelTypeTest.php b/tests/Api/Entity/ModelTypeTest.php index 06faedf..50283fe 100644 --- a/tests/Api/Entity/ModelTypeTest.php +++ b/tests/Api/Entity/ModelTypeTest.php @@ -152,4 +152,132 @@ class ModelTypeTest extends AbstractApiTestCase $this->assertResponseStatusCodeSame(409); } + + public function testPostComponentWithSkeletonStructure(): void + { + $pieceType = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE); + $productType = $this->createModelType('Huile', 'HUILE-001', ModelCategory::PRODUCT); + + $client = $this->createGestionnaireClient(); + $client->request('POST', '/api/model_types', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Pompe complète', + 'code' => 'POMPE-FULL', + 'category' => 'COMPONENT', + 'structure' => [ + 'pieces' => [['typePieceId' => $pieceType->getId()]], + 'products' => [['typeProductId' => $productType->getId(), 'familyCode' => 'LUB']], + 'subcomponents' => [['alias' => 'Sous-pompe', 'familyCode' => 'SP']], + ], + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $client->getResponse()->toArray(); + + $this->assertArrayHasKey('structure', $data); + $this->assertCount(1, $data['structure']['pieces']); + $this->assertSame($pieceType->getId(), $data['structure']['pieces'][0]['typePieceId']); + $this->assertCount(1, $data['structure']['products']); + $this->assertSame($productType->getId(), $data['structure']['products'][0]['typeProductId']); + $this->assertSame('LUB', $data['structure']['products'][0]['familyCode']); + $this->assertCount(1, $data['structure']['subcomponents']); + $this->assertSame('Sous-pompe', $data['structure']['subcomponents'][0]['alias']); + $this->assertSame('SP', $data['structure']['subcomponents'][0]['familyCode']); + } + + public function testGetItemReturnsStructureFromRelations(): void + { + $pieceType = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE); + + $client = $this->createGestionnaireClient(); + $client->request('POST', '/api/model_types', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Pompe', + 'code' => 'POMPE-STRUCT', + 'category' => 'COMPONENT', + 'structure' => [ + 'pieces' => [['typePieceId' => $pieceType->getId()]], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $createdData = $client->getResponse()->toArray(); + + // GET the item and verify structure is returned from relations + $client->request('GET', self::iri('model_types', $createdData['id'])); + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + $this->assertArrayHasKey('structure', $data); + $this->assertCount(1, $data['structure']['pieces']); + $this->assertSame($pieceType->getId(), $data['structure']['pieces'][0]['typePieceId']); + } + + public function testPatchUpdatesSkeletonStructure(): void + { + $pieceType1 = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE); + $pieceType2 = $this->createModelType('Roulement', 'ROUL-001', ModelCategory::PIECE); + + // Create with one piece requirement + $client = $this->createGestionnaireClient(); + $client->request('POST', '/api/model_types', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Pompe', + 'code' => 'POMPE-UPD', + 'category' => 'COMPONENT', + 'structure' => [ + 'pieces' => [['typePieceId' => $pieceType1->getId()]], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $createdData = $client->getResponse()->toArray(); + + // PATCH with two piece requirements (replaces the old one) + $client->request('PATCH', self::iri('model_types', $createdData['id']), [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => [ + 'structure' => [ + 'pieces' => [ + ['typePieceId' => $pieceType1->getId()], + ['typePieceId' => $pieceType2->getId()], + ], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + $this->assertCount(2, $data['structure']['pieces']); + } + + public function testPostPieceTypeWithProductRequirements(): void + { + $productType = $this->createModelType('Graisse', 'GRAISSE-001', ModelCategory::PRODUCT); + + $client = $this->createGestionnaireClient(); + $client->request('POST', '/api/model_types', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Roulement', + 'code' => 'ROUL-PROD', + 'category' => 'PIECE', + 'structure' => [ + 'products' => [['typeProductId' => $productType->getId(), 'familyCode' => 'GR']], + ], + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $client->getResponse()->toArray(); + + $this->assertArrayHasKey('structure', $data); + $this->assertCount(1, $data['structure']['products']); + $this->assertSame($productType->getId(), $data['structure']['products'][0]['typeProductId']); + $this->assertSame('GR', $data['structure']['products'][0]['familyCode']); + } } diff --git a/tests/Api/Entity/PieceTest.php b/tests/Api/Entity/PieceTest.php index 6e4962b..7a3b141 100644 --- a/tests/Api/Entity/PieceTest.php +++ b/tests/Api/Entity/PieceTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Tests\Api\Entity; +use App\Entity\Piece; use App\Enum\ModelCategory; use App\Tests\AbstractApiTestCase; @@ -142,4 +143,48 @@ class PieceTest extends AbstractApiTestCase $this->assertResponseStatusCodeSame(422); } + + public function testGetItemReturnsProductIds(): void + { + $product1 = $this->createProduct('Huile A', 'HUILE-A'); + $product2 = $this->createProduct('Graisse B', 'GRAISSE-B'); + + $piece = $this->createPiece('Joint avec produits', 'REF-JP'); + $piece->addProduct($product1); + $piece->addProduct($product2); + + $em = $this->getEntityManager(); + $em->persist($piece); + $em->flush(); + + $client = $this->createViewerClient(); + $client->request('GET', self::iri('pieces', $piece->getId())); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + $this->assertArrayHasKey('productIds', $data); + $this->assertCount(2, $data['productIds']); + $this->assertContains($product1->getId(), $data['productIds']); + $this->assertContains($product2->getId(), $data['productIds']); + } + + public function testPieceProductRelationSurvivesRefetch(): void + { + $product = $this->createProduct('Huile', 'HUILE-REL'); + $piece = $this->createPiece('Joint relation', 'REF-REL'); + $piece->addProduct($product); + + $em = $this->getEntityManager(); + $em->persist($piece); + $em->flush(); + $em->clear(); + + // Re-fetch the piece to verify relation persisted + $refetched = $em->find(Piece::class, $piece->getId()); + $this->assertNotNull($refetched); + $this->assertCount(1, $refetched->getProducts()); + $this->assertSame($product->getId(), $refetched->getProducts()->first()->getId()); + $this->assertSame([$product->getId()], $refetched->getProductIds()); + } }