diff --git a/src/Service/ModelTypeCategoryConversionService.php b/src/Service/ModelTypeCategoryConversionService.php index 8942b5d..a1ddeab 100644 --- a/src/Service/ModelTypeCategoryConversionService.php +++ b/src/Service/ModelTypeCategoryConversionService.php @@ -200,41 +200,6 @@ final class ModelTypeCategoryConversionService $blockers[] = sprintf('%d composant(s) lié(s) à des machines.', $machineLinked); } - // Check if any composant has pieces or sub-components in structure - $withStructure = $this->connection->fetchAllAssociative( - 'SELECT name, structure FROM composants WHERE typecomposantid = :id AND structure IS NOT NULL', - ['id' => $modelTypeId], - ); - - foreach ($withStructure as $row) { - $structure = json_decode($row['structure'], true); - - if (!is_array($structure)) { - continue; - } - - $hasPieces = !empty($structure['pieces']); - $hasSubcomponents = !empty($structure['subcomponents']); - - if ($hasPieces || $hasSubcomponents) { - $parts = []; - - if ($hasPieces) { - $parts[] = 'pièces'; - } - - if ($hasSubcomponents) { - $parts[] = 'sous-composants'; - } - - $blockers[] = sprintf( - 'Le composant « %s » contient des %s dans sa structure.', - $row['name'], - implode(' et ', $parts), - ); - } - } - // Check slot tables for actual data (post-normalization architecture) $filledPieceSlots = (int) $this->connection->fetchOne( 'SELECT COUNT(*) FROM composant_piece_slots cps diff --git a/tests/Api/DataIntegrityTest.php b/tests/Api/DataIntegrityTest.php new file mode 100644 index 0000000..1a0d3e6 --- /dev/null +++ b/tests/Api/DataIntegrityTest.php @@ -0,0 +1,256 @@ +createMachine('Source Machine'); + $cf = $this->createCustomField('Puissance', 'text', $source); + $this->createCustomFieldValue($cf, '15 kW', $source); + + $client = $this->createGestionnaireClient(); + $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'name' => 'Cloned Machine', + 'siteId' => $source->getSite()->getId(), + ], + ]); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + $clonedMachineId = $data['machine']['id'] ?? null; + $this->assertNotNull($clonedMachineId, 'Clone should return new machine ID'); + $this->assertNotSame($source->getId(), $clonedMachineId); + + // Verify cloned CustomFieldValues reference CustomFields owned by the clone + $em = $this->getEntityManager(); + $clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachineId]); + $clonedFields = $em->getRepository(CustomField::class)->findBy(['machine' => $clonedMachineId]); + + $this->assertNotEmpty($clonedValues, 'Clone should have custom field values'); + $this->assertNotEmpty($clonedFields, 'Clone should have custom field definitions'); + + $clonedFieldIds = array_map(static fn (CustomField $cf) => $cf->getId(), $clonedFields); + + foreach ($clonedValues as $cfv) { + $this->assertContains( + $cfv->getCustomField()->getId(), + $clonedFieldIds, + 'Cloned CustomFieldValue must reference a CustomField owned by the cloned machine, not the source' + ); + } + } + + public function testCloneCustomFieldValuesPreserveValues(): void + { + $source = $this->createMachine('Source'); + $cf1 = $this->createCustomField('Tension', 'text', $source); + $cf2 = $this->createCustomField('Vitesse', 'text', $source); + $this->createCustomFieldValue($cf1, '400 V', $source); + $this->createCustomFieldValue($cf2, '1500 tr/min', $source); + + $client = $this->createGestionnaireClient(); + $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'name' => 'Clone with values', + 'siteId' => $source->getSite()->getId(), + ], + ]); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + $clonedMachineId = $data['machine']['id']; + + $em = $this->getEntityManager(); + $clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachineId]); + + $valueMap = []; + foreach ($clonedValues as $cfv) { + $valueMap[$cfv->getCustomField()->getName()] = $cfv->getValue(); + } + + $this->assertSame('400 V', $valueMap['Tension'] ?? null); + $this->assertSame('1500 tr/min', $valueMap['Vitesse'] ?? null); + } + + // ----------------------------------------------------------------------- + // T2 — Slot PATCH: type validation + 404 on missing piece + // ----------------------------------------------------------------------- + + public function testSlotPatchWithNonExistentPieceReturns404(): void + { + $composant = $this->createComposant('Comp slot 404'); + $pieceType = $this->createModelType('Roulement', 'ROUL-404', ModelCategory::PIECE); + $slot = $this->createComposantPieceSlot($composant, $pieceType, null, 1, 0); + + $client = $this->createGestionnaireClient(); + $client->request('PATCH', '/api/composant-piece-slots/'.$slot->getId(), [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['selectedPieceId' => 'cl_nonexistent_id_123'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testSlotPatchWithWrongPieceTypeReturns422(): void + { + $typeA = $this->createModelType('Roulement', 'ROUL-A', ModelCategory::PIECE); + $typeB = $this->createModelType('Joint', 'JOINT-B', ModelCategory::PIECE); + $composant = $this->createComposant('Comp slot type'); + $slot = $this->createComposantPieceSlot($composant, $typeA, null, 1, 0); + $wrongPiece = $this->createPiece('Joint XY', 'REF-JXY', $typeB); + + $client = $this->createGestionnaireClient(); + $client->request('PATCH', '/api/composant-piece-slots/'.$slot->getId(), [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['selectedPieceId' => $wrongPiece->getId()], + ]); + + $this->assertResponseStatusCodeSame(422); + $data = $client->getResponse()->toArray(false); + $this->assertStringContainsString('Roulement', $data['error']); + } + + public function testSlotPatchWithCorrectPieceTypeSucceeds(): void + { + $type = $this->createModelType('Filtre', 'FILT-OK', ModelCategory::PIECE); + $composant = $this->createComposant('Comp slot OK'); + $slot = $this->createComposantPieceSlot($composant, $type, null, 1, 0); + $piece = $this->createPiece('Filtre Parker', 'REF-FP', $type); + + $client = $this->createGestionnaireClient(); + $client->request('PATCH', '/api/composant-piece-slots/'.$slot->getId(), [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['selectedPieceId' => $piece->getId()], + ]); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + $this->assertSame($piece->getId(), $data['selectedPieceId']); + } + + public function testSlotPatchClearPieceSucceeds(): void + { + $type = $this->createModelType('Joint', 'JOINT-CLR', ModelCategory::PIECE); + $composant = $this->createComposant('Comp slot clear'); + $piece = $this->createPiece('Joint A', 'REF-JA', $type); + $slot = $this->createComposantPieceSlot($composant, $type, $piece, 1, 0); + + $client = $this->createGestionnaireClient(); + $client->request('PATCH', '/api/composant-piece-slots/'.$slot->getId(), [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['selectedPieceId' => null], + ]); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + $this->assertNull($data['selectedPieceId']); + } + + // ----------------------------------------------------------------------- + // T3 — Conversion: blocks when slots have data + // ----------------------------------------------------------------------- + + public function testConversionBlockedWhenSlotsHaveData(): void + { + $compType = $this->createModelType('Pompe', 'POMPE-CONV', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Joint', 'JOINT-CONV', ModelCategory::PIECE); + $composant = $this->createComposant('Pompe A', null, $compType); + $piece = $this->createPiece('Joint réel', 'REF-JR', $pieceType); + $this->createComposantPieceSlot($composant, $pieceType, $piece, 1, 0); + + $client = $this->createGestionnaireClient(); + $client->request('GET', '/api/model_types/'.$compType->getId().'/conversion-check'); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + $this->assertFalse($data['canConvert'], 'Conversion should be blocked when slots have data'); + $this->assertNotEmpty($data['blockers']); + } + + public function testConversionAllowedWhenSlotsEmpty(): void + { + $compType = $this->createModelType('Vanne', 'VANNE-CONV', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Joint', 'JOINT-EMPTY', ModelCategory::PIECE); + $composant = $this->createComposant('Vanne A', null, $compType); + // Slot without selected piece (empty) + $this->createComposantPieceSlot($composant, $pieceType, null, 1, 0); + + $client = $this->createGestionnaireClient(); + $client->request('GET', '/api/model_types/'.$compType->getId().'/conversion-check'); + + $this->assertResponseIsSuccessful(); + $data = $client->getResponse()->toArray(); + + // May still be blocked by machine links, but NOT by slots + $slotBlocker = array_filter( + $data['blockers'] ?? [], + static fn (string $b) => str_contains($b, 'slot') + ); + $this->assertEmpty($slotBlocker, 'Empty slots should not block conversion'); + } + + // ----------------------------------------------------------------------- + // T4 — CustomFieldValue: no orphan CustomField creation + // ----------------------------------------------------------------------- + + public function testCustomFieldValueRejectsOrphanCreation(): void + { + $machine = $this->createMachine('Machine CF'); + + $client = $this->createGestionnaireClient(); + $client->request('POST', '/api/custom-fields/values', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'customFieldName' => 'NonExistentField', + 'value' => 'some value', + 'entityType' => 'machine', + 'entityId' => $machine->getId(), + ], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testCustomFieldValueAcceptsExistingFieldById(): void + { + $machine = $this->createMachine('Machine CF ok'); + $cf = $this->createCustomField('Existing Field', 'text', $machine); + + $client = $this->createGestionnaireClient(); + $client->request('POST', '/api/custom-fields/values', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'customFieldId' => $cf->getId(), + 'value' => 'test value', + 'entityType' => 'machine', + 'entityId' => $machine->getId(), + ], + ]); + + $this->assertResponseIsSuccessful(); + } +}