diff --git a/config/services.yaml b/config/services.yaml index d5e698e..282f9e3 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/src/Controller/ModelTypeSyncController.php b/src/Controller/ModelTypeSyncController.php new file mode 100644 index 0000000..17db51c --- /dev/null +++ b/src/Controller/ModelTypeSyncController.php @@ -0,0 +1,74 @@ +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); + } +} diff --git a/src/Service/ModelTypeSyncService.php b/src/Service/ModelTypeSyncService.php new file mode 100644 index 0000000..3d57c67 --- /dev/null +++ b/src/Service/ModelTypeSyncService.php @@ -0,0 +1,44 @@ + $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); + } +} diff --git a/tests/Api/Controller/ModelTypeSyncControllerTest.php b/tests/Api/Controller/ModelTypeSyncControllerTest.php new file mode 100644 index 0000000..33a37b0 --- /dev/null +++ b/tests/Api/Controller/ModelTypeSyncControllerTest.php @@ -0,0 +1,267 @@ +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']); + } +}