Compare commits

...

2 Commits

Author SHA1 Message Date
Matthieu
509c4d2247 test(data-integrity) : add 10 tests for data loss prevention
Tests cover:
- Clone: CustomFieldValue references cloned definitions, not source
- Clone: values are preserved after cloning
- Slots: 404 on non-existent piece, 422 on wrong type, success on correct type
- Conversion: blocked when slots have filled data, allowed when empty
- CustomField: rejects orphan creation, accepts existing field by ID

Also removes legacy JSON structure check (column no longer exists
after normalization) — replaced by slot table checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:33:18 +01:00
Matthieu
043f6b1ce6 fix(data-integrity) : prevent data loss in clone, slots, conversion and custom fields
- Clone: CustomFieldValue now references cloned CustomField, not source
- Slots: validate piece type matches slot requirement + 404 on missing piece
- Conversion: check slot tables before allowing category conversion + clean orphan skeleton requirements
- CustomFieldValue: prevent creation of orphan CustomField without target entity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:15:05 +01:00
5 changed files with 329 additions and 40 deletions

View File

@@ -43,6 +43,18 @@ class ComposantPieceSlotController extends AbstractController
$slot->setSelectedPiece(null);
} else {
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
if (!$piece) {
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
}
$slotTypePiece = $slot->getTypePiece();
if ($slotTypePiece && $piece->getTypePiece()?->getId() !== $slotTypePiece->getId()) {
return $this->json([
'success' => false,
'error' => sprintf('La pièce doit être de type « %s ».', $slotTypePiece->getName()),
], 422);
}
$slot->setSelectedPiece($piece);
}
}

View File

@@ -196,19 +196,16 @@ class CustomFieldValueController extends AbstractController
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}
$customField = new CustomField();
$customField->setName($customFieldName);
$customField->setType((string) ($payload['customFieldType'] ?? 'text'));
$customField->setRequired((bool) ($payload['customFieldRequired'] ?? false));
$options = $payload['customFieldOptions'] ?? null;
if (is_array($options)) {
$customField->setOptions($options);
// Try to find an existing custom field by name instead of creating an orphan
$existing = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
if ($existing instanceof CustomField) {
return $existing;
}
$this->entityManager->persist($customField);
return $customField;
return $this->json([
'success' => false,
'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName),
], 404);
}
private function resolveTarget(array $payload): array|JsonResponse

View File

@@ -173,6 +173,8 @@ class MachineStructureController extends AbstractController
private function cloneCustomFields(Machine $source, Machine $target): void
{
$cfMap = [];
foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField();
$newCf->setName($cf->getName());
@@ -183,12 +185,20 @@ class MachineStructureController extends AbstractController
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
$cfMap[$cf->getId()] = $newCf;
}
foreach ($source->getCustomFieldValues() as $cfv) {
$originalCf = $cfv->getCustomField();
$newCf = $cfMap[$originalCf->getId()] ?? null;
if (!$newCf) {
continue;
}
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($cfv->getCustomField());
$newValue->setCustomField($newCf);
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}

View File

@@ -200,39 +200,43 @@ 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',
// Check slot tables for actual data (post-normalization architecture)
$filledPieceSlots = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_piece_slots cps
JOIN composants c ON cps.composantid = c.id
WHERE c.typecomposantid = :id AND cps.selectedpieceid IS NOT NULL',
['id' => $modelTypeId],
);
foreach ($withStructure as $row) {
$structure = json_decode($row['structure'], true);
$filledSubSlots = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_subcomponent_slots css
JOIN composants c ON css.composantid = c.id
WHERE c.typecomposantid = :id AND css.selectedcomposantid IS NOT NULL',
['id' => $modelTypeId],
);
if (!is_array($structure)) {
continue;
$filledProductSlots = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_product_slots cps
JOIN composants c ON cps.composantid = c.id
WHERE c.typecomposantid = :id AND cps.selectedproductid IS NOT NULL',
['id' => $modelTypeId],
);
if ($filledPieceSlots > 0 || $filledSubSlots > 0 || $filledProductSlots > 0) {
$parts = [];
if ($filledPieceSlots > 0) {
$parts[] = sprintf('%d slot(s) pièce', $filledPieceSlots);
}
$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),
);
if ($filledSubSlots > 0) {
$parts[] = sprintf('%d slot(s) sous-composant', $filledSubSlots);
}
if ($filledProductSlots > 0) {
$parts[] = sprintf('%d slot(s) produit', $filledProductSlots);
}
$blockers[] = sprintf(
'Des composants ont des données dans leurs slots : %s.',
implode(', ', $parts),
);
}
// Check name collision with existing pieces
@@ -383,12 +387,22 @@ final class ModelTypeCategoryConversionService
['id' => $modelTypeId],
);
// 6. Delete original composants
// 6. Delete original composants (cascades to slot tables)
$this->connection->executeStatement(
'DELETE FROM composants WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
// 6b. Clean up skeleton requirements that only apply to COMPONENT category
$this->connection->executeStatement(
'DELETE FROM skeleton_piece_requirements WHERE modeltypeid = :id',
['id' => $modelTypeId],
);
$this->connection->executeStatement(
'DELETE FROM skeleton_subcomponent_requirements WHERE modeltypeid = :id',
['id' => $modelTypeId],
);
// 7. Update ModelType
$this->connection->executeStatement(
'UPDATE model_types

View File

@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* Data integrity tests — verifies fixes that prevent data loss
* in clone, slots, conversion, and custom fields.
*
* @internal
*/
class DataIntegrityTest extends AbstractApiTestCase
{
// -----------------------------------------------------------------------
// T1 — Clone: CustomFieldValue must reference cloned CustomField
// -----------------------------------------------------------------------
public function testCloneCustomFieldValuesReferenceClonedDefinitions(): void
{
$source = $this->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();
}
}