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>
This commit is contained in:
@@ -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
|
||||
|
||||
256
tests/Api/DataIntegrityTest.php
Normal file
256
tests/Api/DataIntegrityTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user