From 6a20dcce549d1714cd8a3f498022a77a1d210867 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Mar 2026 13:54:47 +0100 Subject: [PATCH] feat(sync) : implement ProductSyncStrategy with tests --- config/services.yaml | 7 + src/Service/Sync/ProductSyncStrategy.php | 152 ++++++++++++++++++ tests/Api/Service/ProductSyncStrategyTest.php | 92 +++++++++++ 3 files changed, 251 insertions(+) create mode 100644 src/Service/Sync/ProductSyncStrategy.php create mode 100644 tests/Api/Service/ProductSyncStrategyTest.php diff --git a/config/services.yaml b/config/services.yaml index 221c412..9647068 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -38,3 +38,10 @@ services: decorates: 'api_platform.openapi.factory' arguments: $decorated: '@.inner' + +when@test: + services: + App\Service\Sync\ProductSyncStrategy: + autowire: true + autoconfigure: true + public: true diff --git a/src/Service/Sync/ProductSyncStrategy.php b/src/Service/Sync/ProductSyncStrategy.php new file mode 100644 index 0000000..6b75b1c --- /dev/null +++ b/src/Service/Sync/ProductSyncStrategy.php @@ -0,0 +1,152 @@ +getCategory(); + } + + public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult + { + $products = $this->em->getRepository(Product::class)->findBy(['typeProduct' => $modelType]); + $existingFields = $this->em->getRepository(CustomField::class)->findBy( + ['typeProduct' => $modelType], + ['orderIndex' => 'ASC'] + ); + + $proposedFields = $newStructure['customFields'] ?? []; + + // Map existing fields by orderIndex + $existingByOrder = []; + foreach ($existingFields as $field) { + $existingByOrder[$field->getOrderIndex()] = $field; + } + + // Map proposed fields by orderIndex + $proposedByOrder = []; + foreach ($proposedFields as $pf) { + $proposedByOrder[$pf['orderIndex']] = $pf; + } + + $addedFields = 0; + $deletedFields = 0; + $modifiedFields = 0; + + // New fields (in proposed but not in existing) + foreach ($proposedByOrder as $orderIndex => $pf) { + if (!isset($existingByOrder[$orderIndex])) { + ++$addedFields; + } elseif ($existingByOrder[$orderIndex]->getType() !== $pf['type']) { + ++$modifiedFields; + } + } + + // Deleted fields (in existing but not in proposed) + foreach ($existingByOrder as $orderIndex => $ef) { + if (!isset($proposedByOrder[$orderIndex])) { + ++$deletedFields; + } + } + + $itemCount = count($products); + + return new SyncPreviewResult( + modelTypeId: $modelType->getId(), + category: 'product', + itemCount: $itemCount, + additions: ['customFieldValues' => $addedFields * $itemCount], + deletions: ['customFieldValues' => $deletedFields * $itemCount], + modifications: ['customFieldValues' => $modifiedFields * $itemCount], + ); + } + + public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult + { + $products = $this->em->getRepository(Product::class)->findBy(['typeProduct' => $modelType]); + $customFields = $this->em->getRepository(CustomField::class)->findBy( + ['typeProduct' => $modelType], + ['orderIndex' => 'ASC'] + ); + + $addedValues = 0; + $deletedValues = 0; + $modifiedValues = 0; + $itemsUpdated = 0; + + foreach ($products as $product) { + $changed = false; + + // Get existing custom field values for this product + $existingValues = $this->em->getRepository(CustomFieldValue::class)->findBy([ + 'product' => $product, + ]); + + // Map existing values by custom field ID + $existingByFieldId = []; + foreach ($existingValues as $cfv) { + $existingByFieldId[$cfv->getCustomField()->getId()] = $cfv; + } + + // For each custom field defined on the model type, ensure a value exists + foreach ($customFields as $cf) { + if (!isset($existingByFieldId[$cf->getId()])) { + // Create missing custom field value + $cfv = new CustomFieldValue(); + $cfv->setCustomField($cf); + $cfv->setProduct($product); + $cfv->setValue(''); + $this->em->persist($cfv); + ++$addedValues; + $changed = true; + } + } + + // Delete orphaned values if confirmDeletions + if ($confirmation->confirmDeletions) { + $fieldIds = array_map(fn (CustomField $cf) => $cf->getId(), $customFields); + foreach ($existingValues as $cfv) { + if (!in_array($cfv->getCustomField()->getId(), $fieldIds, true)) { + $this->em->remove($cfv); + ++$deletedValues; + $changed = true; + } + } + } + + if ($changed) { + $product->incrementVersion(); + ++$itemsUpdated; + } + } + + $this->em->flush(); + + return new SyncExecutionResult( + itemsUpdated: $itemsUpdated, + additions: ['customFieldValues' => $addedValues], + deletions: ['customFieldValues' => $deletedValues], + modifications: ['customFieldValues' => $modifiedValues], + ); + } +} diff --git a/tests/Api/Service/ProductSyncStrategyTest.php b/tests/Api/Service/ProductSyncStrategyTest.php new file mode 100644 index 0000000..d6a7a32 --- /dev/null +++ b/tests/Api/Service/ProductSyncStrategyTest.php @@ -0,0 +1,92 @@ +strategy = static::getContainer()->get(ProductSyncStrategy::class); + } + + public function testSupportsProductCategory(): void + { + $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT); + $this->assertTrue($this->strategy->supports($mt)); + } + + public function testDoesNotSupportComponentCategory(): void + { + $mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT); + $this->assertFalse($this->strategy->supports($mt)); + } + + public function testPreviewNoImpactWhenNoProducts(): void + { + $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT); + $result = $this->strategy->preview($mt, ['customFields' => []]); + $this->assertSame(0, $result->itemCount); + $this->assertFalse($result->hasImpact()); + } + + public function testPreviewDetectsNewCustomField(): void + { + $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT); + $this->createProduct('P1', 'P1-REF', $mt); + + $result = $this->strategy->preview($mt, [ + 'customFields' => [ + ['name' => 'Weight', 'type' => 'text', 'orderIndex' => 0], + ], + ]); + + $this->assertSame(1, $result->itemCount); + $this->assertSame(1, $result->additions['customFieldValues']); + } + + public function testExecuteCreatesCustomFieldValues(): void + { + $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT); + $product = $this->createProduct('P1', 'P1-REF', $mt); + + // Create a custom field on the model type + $this->createCustomField('Weight', 'text', null, null, null, $mt, 0); + + $result = $this->strategy->execute($mt, new SyncConfirmation()); + + $this->assertSame(1, $result->itemsUpdated); + $this->assertSame(1, $result->additions['customFieldValues']); + + // Verify version incremented + $this->getEntityManager()->refresh($product); + $this->assertSame(2, $product->getVersion()); + } + + public function testExecuteIsIdempotent(): void + { + $mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT); + $product = $this->createProduct('P1', 'P1-REF', $mt); + $cf = $this->createCustomField('Weight', 'text', null, null, null, $mt, 0); + + // First execute + $result1 = $this->strategy->execute($mt, new SyncConfirmation()); + $this->assertSame(1, $result1->additions['customFieldValues']); + + // Second execute — no-op + $result2 = $this->strategy->execute($mt, new SyncConfirmation()); + $this->assertSame(0, $result2->itemsUpdated); + } +}