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:
Matthieu
2026-03-13 14:17:57 +01:00
parent 089ca43404
commit 4072abf7ba
4 changed files with 389 additions and 4 deletions

View 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']);
}
}