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:
Matthieu
2026-03-23 17:33:18 +01:00
parent 043f6b1ce6
commit 509c4d2247
2 changed files with 256 additions and 35 deletions

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();
}
}