feat(sync) : add ModelTypeSyncService orchestrator and controller with tests
Implement the sync orchestrator that delegates to tagged strategies via AutowireIterator, and the HTTP controller exposing sync-preview and sync endpoints with transaction wrapping and role-based access control. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
267
tests/Api/Controller/ModelTypeSyncControllerTest.php
Normal file
267
tests/Api/Controller/ModelTypeSyncControllerTest.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Controller;
|
||||
|
||||
use App\Entity\SkeletonPieceRequirement;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ModelTypeSyncControllerTest extends AbstractApiTestCase
|
||||
{
|
||||
// ── sync-preview ────────────────────────────────────────────────
|
||||
|
||||
public function testPreviewReturnsNoImpactWhenNoItems(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync-preview', [
|
||||
'json' => [
|
||||
'structure' => [
|
||||
'pieces' => [],
|
||||
'products' => [],
|
||||
'subcomponents' => [],
|
||||
'customFields' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(0, $data['itemCount']);
|
||||
$this->assertSame($mt->getId(), $data['modelTypeId']);
|
||||
}
|
||||
|
||||
public function testPreviewDetectsNewSlots(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$this->createComposant('C1', $mt);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync-preview', [
|
||||
'json' => [
|
||||
'structure' => [
|
||||
'pieces' => [['typePieceId' => $pieceType->getId(), 'position' => 0]],
|
||||
'products' => [],
|
||||
'subcomponents' => [],
|
||||
'customFields' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['itemCount']);
|
||||
$this->assertSame(1, $data['additions']['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testPreview403ForViewer(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync-preview', [
|
||||
'json' => ['structure' => []],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testPreview401ForUnauthenticated(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
|
||||
$client = $this->createUnauthenticatedClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync-preview', [
|
||||
'json' => ['structure' => []],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testPreview404ForUnknownModelType(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/nonexistent-id/sync-preview', [
|
||||
'json' => ['structure' => []],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// ── sync ────────────────────────────────────────────────────────
|
||||
|
||||
public function testSyncAddsSlots(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$this->createComposant('C1', $mt);
|
||||
|
||||
// Add a skeleton requirement (simulates a PATCH that already happened)
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypePiece($pieceType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => [
|
||||
'confirmDeletions' => false,
|
||||
'confirmTypeChanges' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['itemsUpdated']);
|
||||
$this->assertSame(1, $data['additions']['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testSyncDeletesSlotsWithConfirmation(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
$this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
// No skeleton requirements → slot is orphaned
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => [
|
||||
'confirmDeletions' => true,
|
||||
'confirmTypeChanges' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['deletions']['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testSyncSkipsDeletionsWithoutConfirmation(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
$this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => [
|
||||
'confirmDeletions' => false,
|
||||
'confirmTypeChanges' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(0, $data['deletions']['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testSync403ForViewer(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testSync404ForUnknownModelType(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/nonexistent-id/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testSyncIsIdempotent(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$this->createComposant('C1', $mt);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypePiece($pieceType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
// First sync
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data1 = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data1['itemsUpdated']);
|
||||
|
||||
// Second sync — idempotent, no changes
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data2 = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(0, $data2['itemsUpdated']);
|
||||
}
|
||||
|
||||
public function testSyncWorksForPieceCategory(): void
|
||||
{
|
||||
$mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE);
|
||||
$productType = $this->createModelType('Prod Type', 'PD-001', ModelCategory::PRODUCT);
|
||||
$this->createPiece('P1', 'P1-REF', $mt);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonProductRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypeProduct($productType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['itemsUpdated']);
|
||||
$this->assertSame(1, $data['additions']['productSlots']);
|
||||
}
|
||||
|
||||
public function testSyncWorksForProductCategory(): void
|
||||
{
|
||||
$mt = $this->createModelType('Prod Cat', 'PD-001', ModelCategory::PRODUCT);
|
||||
$this->createProduct('PR1', 'PR1-REF', $mt);
|
||||
$this->createCustomField('CF1', 'text', typeProduct: $mt, orderIndex: 0);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['itemsUpdated']);
|
||||
$this->assertSame(1, $data['additions']['customFieldValues']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user