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>
257 lines
11 KiB
PHP
257 lines
11 KiB
PHP
<?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();
|
|
}
|
|
}
|