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

@@ -56,7 +56,7 @@ when@test:
autoconfigure: true
public: true
# App\Service\ModelTypeSyncService:
# autowire: true
# autoconfigure: true
# public: true
App\Service\ModelTypeSyncService:
autowire: true
autoconfigure: true
public: true

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\DTO\SyncConfirmation;
use App\Repository\ModelTypeRepository;
use App\Service\ModelTypeSyncService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/model_types/{id}')]
final class ModelTypeSyncController extends AbstractController
{
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly ModelTypeSyncService $syncService,
private readonly EntityManagerInterface $em,
) {}
#[Route('/sync-preview', name: 'api_model_type_sync_preview', methods: ['POST'])]
public function preview(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$body = json_decode($request->getContent(), true);
$structure = $body['structure'] ?? [];
$result = $this->syncService->preview($modelType, $structure);
return new JsonResponse($result);
}
#[Route('/sync', name: 'api_model_type_sync', methods: ['POST'])]
public function sync(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$body = json_decode($request->getContent(), true);
$confirmation = new SyncConfirmation(
confirmDeletions: $body['confirmDeletions'] ?? false,
confirmTypeChanges: $body['confirmTypeChanges'] ?? false,
);
$result = $this->em->wrapInTransaction(function () use ($modelType, $confirmation) {
return $this->syncService->execute($modelType, $confirmation);
});
return new JsonResponse($result);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\DTO\SyncConfirmation;
use App\DTO\SyncExecutionResult;
use App\DTO\SyncPreviewResult;
use App\Entity\ModelType;
use App\Service\Sync\SyncStrategyInterface;
use LogicException;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
class ModelTypeSyncService
{
/** @param iterable<SyncStrategyInterface> $strategies */
public function __construct(
#[AutowireIterator('app.sync_strategy')]
private readonly iterable $strategies,
) {}
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($modelType)) {
return $strategy->preview($modelType, $newStructure);
}
}
throw new LogicException('No sync strategy found for category: '.$modelType->getCategory()->value);
}
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($modelType)) {
return $strategy->execute($modelType, $confirmation);
}
}
throw new LogicException('No sync strategy found for category: '.$modelType->getCategory()->value);
}
}

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