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:
@@ -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
|
||||
|
||||
74
src/Controller/ModelTypeSyncController.php
Normal file
74
src/Controller/ModelTypeSyncController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
44
src/Service/ModelTypeSyncService.php
Normal file
44
src/Service/ModelTypeSyncService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
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