feat(sync) : implement ProductSyncStrategy with tests
This commit is contained in:
@@ -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
|
||||
|
||||
152
src/Service/Sync/ProductSyncStrategy.php
Normal file
152
src/Service/Sync/ProductSyncStrategy.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Sync;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Product;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag('app.sync_strategy')]
|
||||
class ProductSyncStrategy implements SyncStrategyInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function supports(ModelType $modelType): bool
|
||||
{
|
||||
return ModelCategory::PRODUCT === $modelType->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],
|
||||
);
|
||||
}
|
||||
}
|
||||
92
tests/Api/Service/ProductSyncStrategyTest.php
Normal file
92
tests/Api/Service/ProductSyncStrategyTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Service;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Service\Sync\ProductSyncStrategy;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ProductSyncStrategyTest extends AbstractApiTestCase
|
||||
{
|
||||
private ProductSyncStrategy $strategy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user