From 65d696d908f39b06da562007cb3b41e38b9b9617 Mon Sep 17 00:00:00 2001 From: Dennis Garding Date: Wed, 20 May 2026 14:34:51 +0200 Subject: [PATCH 1/6] fix: update sales during migration --- src/DependencyInjection/shopware.php | 6 + src/DependencyInjection/writer.php | 2 + src/Migration/Service/ProductSalesUpdater.php | 138 ++++++++++ src/Migration/Writer/OrderWriter.php | 84 +++++- .../Services/ProductSalesUpdaterTest.php | 246 ++++++++++++++++++ tests/Migration/Writer/OrderWriterTest.php | 214 +++++++++++++++ 6 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 src/Migration/Service/ProductSalesUpdater.php create mode 100644 tests/Migration/Services/ProductSalesUpdaterTest.php create mode 100644 tests/Migration/Writer/OrderWriterTest.php diff --git a/src/DependencyInjection/shopware.php b/src/DependencyInjection/shopware.php index 3913c1437..74161f831 100644 --- a/src/DependencyInjection/shopware.php +++ b/src/DependencyInjection/shopware.php @@ -36,6 +36,7 @@ use SwagMigrationAssistant\Migration\Media\Processor\BaseMediaService; use SwagMigrationAssistant\Migration\Media\Processor\HttpDownloadServiceBase; use SwagMigrationAssistant\Migration\MigrationConfiguration; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; use SwagMigrationAssistant\Migration\Writer\AbstractWriter; use SwagMigrationAssistant\Profile\Shopware\Converter\AttributeConverter; use SwagMigrationAssistant\Profile\Shopware\Converter\CategoryAttributeConverter; @@ -997,4 +998,9 @@ service(PromotionDefinition::class), ]) ->tag('shopware.migration.writer'); + + $services->set(ProductSalesUpdater::class) + ->args([ + service(Connection::class), + ]); }; diff --git a/src/DependencyInjection/writer.php b/src/DependencyInjection/writer.php index a44d69298..dc6269d93 100644 --- a/src/DependencyInjection/writer.php +++ b/src/DependencyInjection/writer.php @@ -34,6 +34,7 @@ use Shopware\Core\System\NumberRange\NumberRangeDefinition; use Shopware\Core\System\SalesChannel\SalesChannelDefinition; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationErrorResolutionService; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; use SwagMigrationAssistant\Migration\Writer\AbstractWriter; use SwagMigrationAssistant\Migration\Writer\CategoryAttributeWriter; use SwagMigrationAssistant\Migration\Writer\CategoryWriter; @@ -162,6 +163,7 @@ service(EntityWriter::class), service(OrderDefinition::class), service(StructNormalizer::class), + service(ProductSalesUpdater::class), ]) ->tag('shopware.migration.writer'); diff --git a/src/Migration/Service/ProductSalesUpdater.php b/src/Migration/Service/ProductSalesUpdater.php new file mode 100644 index 000000000..573760646 --- /dev/null +++ b/src/Migration/Service/ProductSalesUpdater.php @@ -0,0 +1,138 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Service; + +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\Connection; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Order\OrderStates; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\RetryableQuery; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; + +#[Package('fundamentals@after-sales')] +final readonly class ProductSalesUpdater +{ + public function __construct( + private Connection $connection, + ) { + } + + /** + * @param array $orderIds + * + * @return array + */ + public function getProductIdsForOrders(array $orderIds): array + { + $orderIds = $this->filterIds($orderIds); + + if ($orderIds === []) { + return []; + } + + $sql = <<<'SQL' + SELECT DISTINCT order_line_item.product_id + FROM order_line_item + WHERE order_line_item.order_id IN (:orderIds) + AND order_line_item.version_id = :liveVersion + AND order_line_item.order_version_id = :liveVersion + AND order_line_item.product_version_id = :liveVersion + AND order_line_item.type = :lineItemType + AND order_line_item.product_id IS NOT NULL + SQL; + + $productIds = $this->connection->fetchFirstColumn( + $sql, + [ + 'orderIds' => Uuid::fromHexToBytesList($orderIds), + 'liveVersion' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'lineItemType' => LineItem::PRODUCT_LINE_ITEM_TYPE, + ], + [ + 'orderIds' => ArrayParameterType::BINARY, + ] + ); + + return \array_map( + static fn (string $productId): string => Uuid::fromBytesToHex($productId), + $productIds + ); + } + + /** + * Recalculates the denormalized product.sales value from persisted live order line items. + * + * @param array $productIds + */ + public function updateProducts(array $productIds): void + { + $productIds = $this->filterIds($productIds); + + if ($productIds === []) { + return; + } + + $productByteIds = Uuid::fromHexToBytesList($productIds); + + $parameters = [ + 'productIds' => $productByteIds, + 'outerProductIds' => $productByteIds, + 'liveVersion' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'lineItemType' => LineItem::PRODUCT_LINE_ITEM_TYPE, + 'cancelledState' => OrderStates::STATE_CANCELLED, + ]; + + $types = [ + 'productIds' => ArrayParameterType::BINARY, + 'outerProductIds' => ArrayParameterType::BINARY, + ]; + + $sql = <<<'SQL' + UPDATE product + LEFT JOIN ( + SELECT order_line_item.product_id, SUM(order_line_item.quantity) AS sales + FROM order_line_item + INNER JOIN `order` + ON `order`.id = order_line_item.order_id + AND `order`.version_id = order_line_item.order_version_id + AND `order`.version_id = :liveVersion + INNER JOIN state_machine_state + ON state_machine_state.id = `order`.state_id + WHERE order_line_item.product_id IN (:productIds) + AND order_line_item.version_id = :liveVersion + AND order_line_item.product_version_id = :liveVersion + AND order_line_item.type = :lineItemType + AND state_machine_state.technical_name != :cancelledState + GROUP BY order_line_item.product_id + ) product_sales + ON product_sales.product_id = product.id + SET product.sales = COALESCE(product_sales.sales, 0), + product.updated_at = NOW() + WHERE product.id IN (:outerProductIds) + AND product.version_id = :liveVersion + SQL; + + RetryableQuery::retryable($this->connection, function () use ($sql, $parameters, $types): void { + $this->connection->executeStatement($sql, $parameters, $types); + }); + } + + /** + * @param array $ids + * + * @return array + */ + private function filterIds(array $ids): array + { + $filteredIds = \array_filter($ids, static fn (string $id): bool => Uuid::isValid($id)); + + return \array_unique($filteredIds); + } +} diff --git a/src/Migration/Writer/OrderWriter.php b/src/Migration/Writer/OrderWriter.php index 338cf9870..f7d09aee2 100644 --- a/src/Migration/Writer/OrderWriter.php +++ b/src/Migration/Writer/OrderWriter.php @@ -7,12 +7,15 @@ namespace SwagMigrationAssistant\Migration\Writer; +use Shopware\Core\Checkout\Order\OrderDefinition; use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition; +use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Struct\Serializer\StructNormalizer; use SwagMigrationAssistant\Migration\DataSelection\DefaultEntities; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; #[Package('fundamentals@after-sales')] class OrderWriter extends AbstractWriter @@ -21,6 +24,7 @@ public function __construct( EntityWriterInterface $entityWriter, EntityDefinition $definition, private readonly StructNormalizer $structNormalizer, + private readonly ProductSalesUpdater $productSalesUpdater, ) { parent::__construct($entityWriter, $definition); } @@ -44,6 +48,84 @@ public function writeData(array $data, Context $context): array } unset($item); - return parent::writeData($data, $context); + // Re-migration case: product A is only visible before a line item switches to product B. + $orderIdsBeforeWrite = $this->extractOrderIdsFromPayload($data); + $productIdsBeforeWrite = $this->productSalesUpdater->getProductIdsForOrders($orderIdsBeforeWrite); + + // Write new orders or update already migrated orders. + $result = parent::writeData($data, $context); + + // Merge order IDs from payload and write result to also cover inserted orders. + $writtenOrderIds = $this->extractOrderIdsFromWriteResults($result); + $orderIds = $this->merge($orderIdsBeforeWrite, $writtenOrderIds); + $productIdsAfterWrite = $this->productSalesUpdater->getProductIdsForOrders($orderIds); + $affectedProductIds = $this->merge($productIdsBeforeWrite, $productIdsAfterWrite); + + // Recalculate every product that was affected before or after write. + $this->productSalesUpdater->updateProducts($affectedProductIds); + + return $result; + } + + /** + * Reads the target order IDs from the converted migration payload before the DAL upsert. + * These IDs are needed to find products that may disappear from an existing order during re-migration. + * + * @param array $payload + * + * @return list + */ + private function extractOrderIdsFromPayload(array $payload): array + { + $orderIds = []; + + foreach ($payload as $item) { + $id = $item['id'] ?? null; + + if (\is_string($id)) { + $orderIds[] = $id; + } + } + + return \array_values(\array_unique($orderIds)); + } + + /** + * @param array> $writeResults + * + * @return list + */ + private function extractOrderIdsFromWriteResults(array $writeResults): array + { + $orderIds = []; + + foreach ($writeResults[OrderDefinition::ENTITY_NAME] ?? [] as $writeResult) { + $primaryKey = $writeResult->getPrimaryKey(); + + if (\is_string($primaryKey)) { + $orderIds[] = $primaryKey; + + continue; + } + + $id = $primaryKey['id'] ?? null; + + if (\is_string($id)) { + $orderIds[] = $id; + } + } + + return \array_values(\array_unique($orderIds)); + } + + /** + * @param array $arrayOne + * @param array $arrayTwo + * + * @return list + */ + private function merge(array $arrayOne, array $arrayTwo): array + { + return \array_values(\array_unique(\array_merge($arrayOne, $arrayTwo))); } } diff --git a/tests/Migration/Services/ProductSalesUpdaterTest.php b/tests/Migration/Services/ProductSalesUpdaterTest.php new file mode 100644 index 000000000..94a24d96f --- /dev/null +++ b/tests/Migration/Services/ProductSalesUpdaterTest.php @@ -0,0 +1,246 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\Migration\Services; + +use Doctrine\DBAL\Connection; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice; +use Shopware\Core\Checkout\Cart\Price\Struct\QuantityPriceDefinition; +use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTaxCollection; +use Shopware\Core\Checkout\Cart\Tax\Struct\TaxRuleCollection; +use Shopware\Core\Checkout\Order\OrderCollection; +use Shopware\Core\Checkout\Order\OrderStates; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\BasicTestDataBehaviour; +use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\Test\Integration\Builder\Order\OrderBuilder; +use Shopware\Core\Test\Stub\Framework\IdsCollection; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; + +/** + * @phpstan-type OrderLineItemFixture array{ + * id: string, + * identifier: string, + * referencedId: string, + * productId: string, + * productVersionId: string, + * quantity: int, + * type: string, + * label: string, + * price: CalculatedPrice, + * priceDefinition: QuantityPriceDefinition, + * payload: array{productNumber: string}, + * good: bool, + * removable: bool, + * stackable: bool, + * position: int + * } + */ +#[Package('fundamentals@after-sales')] +class ProductSalesUpdaterTest extends TestCase +{ + use BasicTestDataBehaviour; + use IntegrationTestBehaviour; + + private Connection $connection; + + private ProductSalesUpdater $productSalesUpdater; + + private Context $context; + + /** + * @var EntityRepository + */ + private EntityRepository $orderRepository; + + protected function setUp(): void + { + $this->connection = static::getContainer()->get(Connection::class); + $this->orderRepository = static::getContainer()->get('order.repository'); + $this->context = Context::createDefaultContext(); + $this->productSalesUpdater = new ProductSalesUpdater($this->connection); + } + + public function testUpdatesProductSalesFromPersistedOrderLineItems(): void + { + $soldProductId = Uuid::randomHex(); + $cancelledProductId = Uuid::randomHex(); + + $this->createProduct($soldProductId); + $this->createProduct($cancelledProductId); + + $openOrderId = $this->createOrderWithProductLineItem($soldProductId, 3, OrderStates::STATE_OPEN); + $cancelledOrderId = $this->createOrderWithProductLineItem($cancelledProductId, 7, OrderStates::STATE_CANCELLED); + + static::assertEqualsCanonicalizing( + [$soldProductId, $cancelledProductId], + $this->productSalesUpdater->getProductIdsForOrders([$openOrderId, $cancelledOrderId, 'invalid', $openOrderId]) + ); + + $productIds = [$soldProductId, $cancelledProductId, 'invalid', $soldProductId]; + + $this->productSalesUpdater->updateProducts($productIds); + + static::assertSame(3, $this->fetchProductSales($soldProductId)); + static::assertSame(0, $this->fetchProductSales($cancelledProductId)); + + // A remigration can process the same order data again. The updater recalculates + // from persisted line items and must not increment the existing sales values. + $this->productSalesUpdater->updateProducts($productIds); + + static::assertSame(3, $this->fetchProductSales($soldProductId)); + static::assertSame(0, $this->fetchProductSales($cancelledProductId)); + } + + public function testUpdateSumsProductSalesAcrossMultipleOrders(): void + { + $productId = Uuid::randomHex(); + + $this->createProduct($productId); + + $firstOrderId = $this->createOrderWithProductLineItem($productId, 3, OrderStates::STATE_OPEN); + $secondOrderId = $this->createOrderWithProductLineItem($productId, 2, OrderStates::STATE_OPEN); + $cancelledOrderId = $this->createOrderWithProductLineItem($productId, 9, OrderStates::STATE_CANCELLED); + + $this->updateProductsForOrders([$firstOrderId, $secondOrderId, $cancelledOrderId]); + + static::assertSame(5, $this->fetchProductSales($productId)); + } + + public function testRemigrationWithChangedQuantityRecalculatesProductSales(): void + { + $productId = Uuid::randomHex(); + + $this->createProduct($productId); + + $orderId = $this->createOrderWithProductLineItem($productId, 3, OrderStates::STATE_OPEN); + + $this->updateProductsForOrders([$orderId]); + + static::assertSame(3, $this->fetchProductSales($productId)); + + // During remigration the persisted line item can be overwritten. The next + // updater run must use the new quantity, not add it to the previous sales value. + $this->updateOrderLineItemQuantity($orderId, $productId, 5); + $this->updateProductsForOrders([$orderId]); + + static::assertSame(5, $this->fetchProductSales($productId)); + } + + private function createProduct(string $productId): void + { + $productNumber = 'product-sales-' . $productId; + + $this->connection->insert('product', [ + 'id' => Uuid::fromHexToBytes($productId), + 'version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'product_number' => $productNumber, + 'active' => 1, + 'stock' => 10, + 'available_stock' => 10, + 'available' => 1, + 'sales' => 99, + 'child_count' => 0, + 'created_at' => (new \DateTimeImmutable())->format(Defaults::STORAGE_DATE_TIME_FORMAT), + ]); + } + + private function createOrderWithProductLineItem(string $productId, int $quantity, string $state): string + { + $ids = new IdsCollection(); + $orderKey = 'order-' . Uuid::randomHex(); + $lineItemId = Uuid::randomHex(); + + $order = (new OrderBuilder($ids, $orderKey)) + ->add('stateId', $this->getStateMachineState(OrderStates::STATE_MACHINE, $state)) + ->add('lineItems', [$this->createProductLineItem($lineItemId, $productId, $quantity)]) + ->build(); + + $this->orderRepository->create([$order], $this->context); + + return $ids->get($orderKey); + } + + private function updateOrderLineItemQuantity(string $orderId, string $productId, int $quantity): void + { + $affectedRows = $this->connection->update('order_line_item', [ + 'quantity' => $quantity, + ], [ + 'order_id' => Uuid::fromHexToBytes($orderId), + 'order_version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + 'product_id' => Uuid::fromHexToBytes($productId), + 'product_version_id' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + ]); + + static::assertSame(1, $affectedRows); + } + + /** + * @param array $orderIds + */ + private function updateProductsForOrders(array $orderIds): void + { + $this->productSalesUpdater->updateProducts( + $this->productSalesUpdater->getProductIdsForOrders($orderIds) + ); + } + + /** + * @return OrderLineItemFixture + */ + private function createProductLineItem(string $lineItemId, string $productId, int $quantity): array + { + $unitPrice = 10.0; + + return [ + 'id' => $lineItemId, + 'identifier' => $productId, + 'referencedId' => $productId, + 'productId' => $productId, + 'productVersionId' => Defaults::LIVE_VERSION, + 'quantity' => $quantity, + 'type' => LineItem::PRODUCT_LINE_ITEM_TYPE, + 'label' => 'Product sales test product', + 'price' => new CalculatedPrice( + $unitPrice, + $unitPrice * $quantity, + new CalculatedTaxCollection(), + new TaxRuleCollection(), + $quantity + ), + 'priceDefinition' => new QuantityPriceDefinition($unitPrice, new TaxRuleCollection(), $quantity), + 'payload' => ['productNumber' => 'product-sales-' . $productId], + 'good' => true, + 'removable' => true, + 'stackable' => true, + 'position' => 1, + ]; + } + + private function fetchProductSales(string $productId): int + { + $sales = $this->connection->fetchOne(' + SELECT sales + FROM product + WHERE id = :productId + AND version_id = :liveVersion + ', [ + 'productId' => Uuid::fromHexToBytes($productId), + 'liveVersion' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), + ]); + + static::assertNotFalse($sales); + + return (int) $sales; + } +} diff --git a/tests/Migration/Writer/OrderWriterTest.php b/tests/Migration/Writer/OrderWriterTest.php new file mode 100644 index 000000000..cf9c71e3c --- /dev/null +++ b/tests/Migration/Writer/OrderWriterTest.php @@ -0,0 +1,214 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\Migration\Writer; + +use Doctrine\DBAL\ArrayParameterType; +use Doctrine\DBAL\Connection; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Order\OrderDefinition; +use Shopware\Core\Checkout\Order\OrderStates; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityWriteResult; +use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Struct\Serializer\StructNormalizer; +use Shopware\Core\Framework\Uuid\Uuid; +use SwagMigrationAssistant\Migration\Service\ProductSalesUpdater; +use SwagMigrationAssistant\Migration\Writer\AbstractWriter; +use SwagMigrationAssistant\Migration\Writer\OrderWriter; + +#[Package('fundamentals@after-sales')] +class OrderWriterTest extends TestCase +{ + private EntityWriterInterface&MockObject $entityWriter; + + private Connection&MockObject $connection; + + private OrderWriter $orderWriter; + + protected function setUp(): void + { + $this->entityWriter = $this->createMock(EntityWriterInterface::class); + $this->connection = $this->createMock(Connection::class); + $this->orderWriter = new OrderWriter( + $this->entityWriter, + new OrderDefinition(), + $this->createMock(StructNormalizer::class), + new ProductSalesUpdater($this->connection) + ); + } + + public function testWriteDataUpdatesProductSalesForPersistedOrderLineItems(): void + { + $orderId = Uuid::randomHex(); + $productId = Uuid::randomHex(); + $context = Context::createDefaultContext(); + $orderData = [ + [ + 'id' => $orderId, + 'lineItems' => [ + [ + 'productId' => $productId, + 'quantity' => 3, + ], + ], + ], + ]; + $writeResult = [ + OrderDefinition::ENTITY_NAME => [ + new EntityWriteResult( + $orderId, + [], + OrderDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_INSERT + ), + ], + ]; + + $this->entityWriter->expects($this->once()) + ->method('upsert') + ->willReturn($writeResult); + + $this->connection->expects($this->once()) + ->method('executeStatement') + ->with( + static::callback(static fn (string $sql): bool => \str_contains($sql, 'UPDATE product')), + static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$productId])), + [ + 'productIds' => ArrayParameterType::BINARY, + 'outerProductIds' => ArrayParameterType::BINARY, + ] + ) + ->willReturn(1); + + $this->connection->expects($this->exactly(2)) + ->method('fetchFirstColumn') + ->willReturnOnConsecutiveCalls([], [Uuid::fromHexToBytes($productId)]); + + $result = $this->orderWriter->writeData($orderData, $context); + + static::assertSame($writeResult, $result); + static::assertTrue($context->hasExtension(AbstractWriter::EXTENSION_NAME)); + } + + public function testWriteDataExcludesCancelledOrdersFromProductSales(): void + { + $orderId = Uuid::randomHex(); + $productId = Uuid::randomHex(); + $context = Context::createDefaultContext(); + $writeResult = [ + OrderDefinition::ENTITY_NAME => [ + new EntityWriteResult( + $orderId, + [], + OrderDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_INSERT + ), + ], + ]; + + $this->entityWriter->expects($this->once()) + ->method('upsert') + ->willReturn($writeResult); + + $this->connection->expects($this->once()) + ->method('executeStatement') + ->with( + static::callback(static fn (string $sql): bool => \str_contains($sql, 'state_machine_state.technical_name != :cancelledState')), + static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$productId]) + && ($parameters['cancelledState'] ?? null) === OrderStates::STATE_CANCELLED), + [ + 'productIds' => ArrayParameterType::BINARY, + 'outerProductIds' => ArrayParameterType::BINARY, + ] + ) + ->willReturn(1); + + $this->connection->expects($this->exactly(2)) + ->method('fetchFirstColumn') + ->willReturnOnConsecutiveCalls([], [Uuid::fromHexToBytes($productId)]); + + $this->orderWriter->writeData([['id' => $orderId]], $context); + } + + public function testWriteDataUpdatesPreviousAndCurrentProductsWhenExistingLineItemChangesProduct(): void + { + $orderId = Uuid::randomHex(); + $previousProductId = Uuid::randomHex(); + $currentProductId = Uuid::randomHex(); + $context = Context::createDefaultContext(); + $writeResult = [ + OrderDefinition::ENTITY_NAME => [ + new EntityWriteResult( + $orderId, + [], + OrderDefinition::ENTITY_NAME, + EntityWriteResult::OPERATION_UPDATE + ), + ], + ]; + + $this->entityWriter->expects($this->once()) + ->method('upsert') + ->willReturn($writeResult); + + $this->connection->expects($this->exactly(2)) + ->method('fetchFirstColumn') + ->willReturnOnConsecutiveCalls( + [Uuid::fromHexToBytes($previousProductId)], + [Uuid::fromHexToBytes($currentProductId)] + ); + + $this->connection->expects($this->once()) + ->method('executeStatement') + ->with( + static::callback(static fn (string $sql): bool => \str_contains($sql, 'UPDATE product')), + static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$previousProductId, $currentProductId])), + [ + 'productIds' => ArrayParameterType::BINARY, + 'outerProductIds' => ArrayParameterType::BINARY, + ] + ) + ->willReturn(1); + + $this->orderWriter->writeData([ + [ + 'id' => $orderId, + 'lineItems' => [ + [ + 'productId' => $currentProductId, + ], + ], + ], + ], $context); + } + + /** + * @param array|string> $parameters + * @param list $expectedProductIds + */ + private static function containsProductIds(array $parameters, array $expectedProductIds): bool + { + $productIds = $parameters['productIds'] ?? null; + + if (!\is_array($productIds)) { + return false; + } + + $actualProductIds = \array_map( + static fn (string $productId): string => Uuid::fromBytesToHex($productId), + $productIds + ); + + \sort($actualProductIds); + \sort($expectedProductIds); + + return $actualProductIds === $expectedProductIds; + } +} From 33eb492486f1257f2bf6bdd1ade578e6c05a1421 Mon Sep 17 00:00:00 2001 From: Dennis Garding Date: Thu, 21 May 2026 11:06:57 +0200 Subject: [PATCH 2/6] add changelog --- CHANGELOG.md | 2 ++ CHANGELOG_de-DE.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f61f42ce..cecf93aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # NEXT +- #12748 - Fixed order migration so `product.sales` is recalculated from persisted order line items after migrated orders are written. Cancelled orders are excluded from the sales calculation. + # 18.0.0 - Fixed Shopware 6 sales channel migration so additional sales channels, such as Social Shopping, are now migrated correctly when their sales channel types already exist in the target shop before the migration starts diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md index c58d31f5d..4a3128cb4 100644 --- a/CHANGELOG_de-DE.md +++ b/CHANGELOG_de-DE.md @@ -1,5 +1,7 @@ # NEXT +- #12748 - Die Migration von Bestellungen wurde korrigiert, sodass `product.sales` nach dem Schreiben migrierter Bestellungen anhand der persistierten Bestellpositionen neu berechnet wird. Stornierte Bestellungen werden dabei nicht berücksichtigt. + # 18.0.0 - #14435 - Die Migration von Shopware 6 Sales Channel wurde korrigiert, sodass zusätzliche Sales Channel wie Social Shopping jetzt korrekt migriert werden, wenn ihre Sales-Channel-Typen vor dem Start der Migration im Zielshop vorhanden sind. From ce32ac693f62eaab0f49b6c20dfc683d73464b05 Mon Sep 17 00:00:00 2001 From: Dennis Garding Date: Tue, 26 May 2026 06:52:02 +0200 Subject: [PATCH 3/6] consider other orderstates --- src/Migration/Service/ProductSalesUpdater.php | 54 ++++++++++++------- .../Services/ProductSalesUpdaterTest.php | 32 ++++++++--- tests/Migration/Writer/OrderWriterTest.php | 36 ++++++++----- 3 files changed, 84 insertions(+), 38 deletions(-) diff --git a/src/Migration/Service/ProductSalesUpdater.php b/src/Migration/Service/ProductSalesUpdater.php index 573760646..61c5f0264 100644 --- a/src/Migration/Service/ProductSalesUpdater.php +++ b/src/Migration/Service/ProductSalesUpdater.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Shopware\Core\Checkout\Cart\LineItem\LineItem; +use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates; use Shopware\Core\Checkout\Order\OrderStates; use Shopware\Core\Defaults; use Shopware\Core\Framework\DataAbstractionLayer\Doctrine\RetryableQuery; @@ -86,37 +87,54 @@ public function updateProducts(array $productIds): void 'outerProductIds' => $productByteIds, 'liveVersion' => Uuid::fromHexToBytes(Defaults::LIVE_VERSION), 'lineItemType' => LineItem::PRODUCT_LINE_ITEM_TYPE, - 'cancelledState' => OrderStates::STATE_CANCELLED, + 'orderStates' => [ + OrderStates::STATE_OPEN, + OrderStates::STATE_COMPLETED, + ], + 'transactionStates' => [ + OrderTransactionStates::STATE_CANCELLED, + OrderTransactionStates::STATE_REFUNDED, + ], ]; $types = [ 'productIds' => ArrayParameterType::BINARY, 'outerProductIds' => ArrayParameterType::BINARY, + 'orderStates' => ArrayParameterType::STRING, + 'transactionStates' => ArrayParameterType::STRING, ]; $sql = <<<'SQL' UPDATE product LEFT JOIN ( - SELECT order_line_item.product_id, SUM(order_line_item.quantity) AS sales - FROM order_line_item - INNER JOIN `order` - ON `order`.id = order_line_item.order_id - AND `order`.version_id = order_line_item.order_version_id - AND `order`.version_id = :liveVersion - INNER JOIN state_machine_state - ON state_machine_state.id = `order`.state_id - WHERE order_line_item.product_id IN (:productIds) - AND order_line_item.version_id = :liveVersion - AND order_line_item.product_version_id = :liveVersion - AND order_line_item.type = :lineItemType - AND state_machine_state.technical_name != :cancelledState - GROUP BY order_line_item.product_id + SELECT order_line_item.product_id, SUM(order_line_item.quantity) AS sales + FROM order_line_item + INNER JOIN `order` + ON `order`.id = order_line_item.order_id + AND `order`.version_id = order_line_item.order_version_id + AND `order`.version_id = :liveVersion + INNER JOIN state_machine_state order_state + ON order_state.id = `order`.state_id + INNER JOIN order_transaction primary_transaction + ON primary_transaction.id = `order`.primary_order_transaction_id + AND primary_transaction.version_id = `order`.primary_order_transaction_version_id + AND primary_transaction.order_id = `order`.id + AND primary_transaction.order_version_id = `order`.version_id + INNER JOIN state_machine_state transaction_state + ON transaction_state.id = primary_transaction.state_id + WHERE order_line_item.product_id IN (:productIds) + AND order_line_item.version_id = :liveVersion + AND order_line_item.product_version_id = :liveVersion + AND order_line_item.type = :lineItemType + AND order_state.technical_name IN (:orderStates) + AND transaction_state.technical_name NOT IN (:transactionStates) + GROUP BY order_line_item.product_id ) product_sales - ON product_sales.product_id = product.id + ON product_sales.product_id = product.id SET product.sales = COALESCE(product_sales.sales, 0), - product.updated_at = NOW() + product.updated_at = NOW() WHERE product.id IN (:outerProductIds) - AND product.version_id = :liveVersion + AND product.version_id = :liveVersion SQL; RetryableQuery::retryable($this->connection, function () use ($sql, $parameters, $types): void { diff --git a/tests/Migration/Services/ProductSalesUpdaterTest.php b/tests/Migration/Services/ProductSalesUpdaterTest.php index 94a24d96f..fbd9c9926 100644 --- a/tests/Migration/Services/ProductSalesUpdaterTest.php +++ b/tests/Migration/Services/ProductSalesUpdaterTest.php @@ -14,6 +14,7 @@ use Shopware\Core\Checkout\Cart\Price\Struct\QuantityPriceDefinition; use Shopware\Core\Checkout\Cart\Tax\Struct\CalculatedTaxCollection; use Shopware\Core\Checkout\Cart\Tax\Struct\TaxRuleCollection; +use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates; use Shopware\Core\Checkout\Order\OrderCollection; use Shopware\Core\Checkout\Order\OrderStates; use Shopware\Core\Defaults; @@ -75,24 +76,32 @@ public function testUpdatesProductSalesFromPersistedOrderLineItems(): void { $soldProductId = Uuid::randomHex(); $cancelledProductId = Uuid::randomHex(); + $refundedProductId = Uuid::randomHex(); + $transactionCancelledProductId = Uuid::randomHex(); $this->createProduct($soldProductId); $this->createProduct($cancelledProductId); + $this->createProduct($refundedProductId); + $this->createProduct($transactionCancelledProductId); $openOrderId = $this->createOrderWithProductLineItem($soldProductId, 3, OrderStates::STATE_OPEN); $cancelledOrderId = $this->createOrderWithProductLineItem($cancelledProductId, 7, OrderStates::STATE_CANCELLED); + $refundedOrderId = $this->createOrderWithProductLineItem($refundedProductId, 5, OrderStates::STATE_COMPLETED, OrderTransactionStates::STATE_REFUNDED); + $transactionCancelledOrderId = $this->createOrderWithProductLineItem($transactionCancelledProductId, 11, OrderStates::STATE_COMPLETED, OrderTransactionStates::STATE_CANCELLED); static::assertEqualsCanonicalizing( - [$soldProductId, $cancelledProductId], - $this->productSalesUpdater->getProductIdsForOrders([$openOrderId, $cancelledOrderId, 'invalid', $openOrderId]) + [$soldProductId, $cancelledProductId, $refundedProductId, $transactionCancelledProductId], + $this->productSalesUpdater->getProductIdsForOrders([$openOrderId, $cancelledOrderId, $refundedOrderId, $transactionCancelledOrderId, 'invalid', $openOrderId]) ); - $productIds = [$soldProductId, $cancelledProductId, 'invalid', $soldProductId]; + $productIds = [$soldProductId, $cancelledProductId, $refundedProductId, $transactionCancelledProductId, 'invalid', $soldProductId]; $this->productSalesUpdater->updateProducts($productIds); static::assertSame(3, $this->fetchProductSales($soldProductId)); static::assertSame(0, $this->fetchProductSales($cancelledProductId)); + static::assertSame(0, $this->fetchProductSales($refundedProductId)); + static::assertSame(0, $this->fetchProductSales($transactionCancelledProductId)); // A remigration can process the same order data again. The updater recalculates // from persisted line items and must not increment the existing sales values. @@ -100,6 +109,8 @@ public function testUpdatesProductSalesFromPersistedOrderLineItems(): void static::assertSame(3, $this->fetchProductSales($soldProductId)); static::assertSame(0, $this->fetchProductSales($cancelledProductId)); + static::assertSame(0, $this->fetchProductSales($refundedProductId)); + static::assertSame(0, $this->fetchProductSales($transactionCancelledProductId)); } public function testUpdateSumsProductSalesAcrossMultipleOrders(): void @@ -109,7 +120,7 @@ public function testUpdateSumsProductSalesAcrossMultipleOrders(): void $this->createProduct($productId); $firstOrderId = $this->createOrderWithProductLineItem($productId, 3, OrderStates::STATE_OPEN); - $secondOrderId = $this->createOrderWithProductLineItem($productId, 2, OrderStates::STATE_OPEN); + $secondOrderId = $this->createOrderWithProductLineItem($productId, 2, OrderStates::STATE_COMPLETED); $cancelledOrderId = $this->createOrderWithProductLineItem($productId, 9, OrderStates::STATE_CANCELLED); $this->updateProductsForOrders([$firstOrderId, $secondOrderId, $cancelledOrderId]); @@ -155,14 +166,23 @@ private function createProduct(string $productId): void ]); } - private function createOrderWithProductLineItem(string $productId, int $quantity, string $state): string - { + private function createOrderWithProductLineItem( + string $productId, + int $quantity, + string $state, + string $transactionState = OrderTransactionStates::STATE_OPEN + ): string { $ids = new IdsCollection(); $orderKey = 'order-' . Uuid::randomHex(); + $transactionKey = 'transaction-' . Uuid::randomHex(); $lineItemId = Uuid::randomHex(); $order = (new OrderBuilder($ids, $orderKey)) ->add('stateId', $this->getStateMachineState(OrderStates::STATE_MACHINE, $state)) + ->add('primaryOrderTransactionId', $ids->get($transactionKey)) + ->addTransaction($transactionKey, [ + 'stateId' => $this->getStateMachineState(OrderTransactionStates::STATE_MACHINE, $transactionState), + ]) ->add('lineItems', [$this->createProductLineItem($lineItemId, $productId, $quantity)]) ->build(); diff --git a/tests/Migration/Writer/OrderWriterTest.php b/tests/Migration/Writer/OrderWriterTest.php index cf9c71e3c..13b58a305 100644 --- a/tests/Migration/Writer/OrderWriterTest.php +++ b/tests/Migration/Writer/OrderWriterTest.php @@ -11,6 +11,7 @@ use Doctrine\DBAL\Connection; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates; use Shopware\Core\Checkout\Order\OrderDefinition; use Shopware\Core\Checkout\Order\OrderStates; use Shopware\Core\Framework\Context; @@ -80,10 +81,7 @@ public function testWriteDataUpdatesProductSalesForPersistedOrderLineItems(): vo ->with( static::callback(static fn (string $sql): bool => \str_contains($sql, 'UPDATE product')), static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$productId])), - [ - 'productIds' => ArrayParameterType::BINARY, - 'outerProductIds' => ArrayParameterType::BINARY, - ] + self::productSalesParameterTypes() ) ->willReturn(1); @@ -120,13 +118,13 @@ public function testWriteDataExcludesCancelledOrdersFromProductSales(): void $this->connection->expects($this->once()) ->method('executeStatement') ->with( - static::callback(static fn (string $sql): bool => \str_contains($sql, 'state_machine_state.technical_name != :cancelledState')), + static::callback(static fn (string $sql): bool => \str_contains($sql, 'order_state.technical_name IN (:orderStates)') + && \str_contains($sql, 'transaction_state.technical_name NOT IN (:transactionStates)') + && \str_contains($sql, 'INNER JOIN order_transaction primary_transaction')), static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$productId]) - && ($parameters['cancelledState'] ?? null) === OrderStates::STATE_CANCELLED), - [ - 'productIds' => ArrayParameterType::BINARY, - 'outerProductIds' => ArrayParameterType::BINARY, - ] + && ($parameters['orderStates'] ?? null) === [OrderStates::STATE_OPEN, OrderStates::STATE_COMPLETED] + && ($parameters['transactionStates'] ?? null) === [OrderTransactionStates::STATE_CANCELLED, OrderTransactionStates::STATE_REFUNDED]), + self::productSalesParameterTypes() ) ->willReturn(1); @@ -170,10 +168,7 @@ public function testWriteDataUpdatesPreviousAndCurrentProductsWhenExistingLineIt ->with( static::callback(static fn (string $sql): bool => \str_contains($sql, 'UPDATE product')), static::callback(static fn (array $parameters): bool => self::containsProductIds($parameters, [$previousProductId, $currentProductId])), - [ - 'productIds' => ArrayParameterType::BINARY, - 'outerProductIds' => ArrayParameterType::BINARY, - ] + self::productSalesParameterTypes() ) ->willReturn(1); @@ -211,4 +206,17 @@ private static function containsProductIds(array $parameters, array $expectedPro return $actualProductIds === $expectedProductIds; } + + /** + * @return array + */ + private static function productSalesParameterTypes(): array + { + return [ + 'productIds' => ArrayParameterType::BINARY, + 'outerProductIds' => ArrayParameterType::BINARY, + 'orderStates' => ArrayParameterType::STRING, + 'transactionStates' => ArrayParameterType::STRING, + ]; + } } From 73b14957a38a31936507edbf7ff867a1b92a8b1a Mon Sep 17 00:00:00 2001 From: Dennis Garding Date: Tue, 26 May 2026 06:57:31 +0200 Subject: [PATCH 4/6] add entry to upgrade.md --- UPGRADE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index 40500f5e5..f95c6c6b2 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,8 @@ # NEXT +- [BREAKING] [#178](https://github.com/shopware/SwagMigrationAssistant/pull/190) - fix!: update product.sales during migration + - [BREAKING] Added required constructor parameter `\SwagMigrationAssistant\Migration\Service\ProductSalesUpdater` to `\SwagMigrationAssistant\Migration\Writer\OrderWriter` + # 18.0.0 - [BREAKING] [#178](https://github.com/shopware/SwagMigrationAssistant/pull/178) - fix!: unsupported sales channel migration From 2abd2b01e34739447c3560ffdc9d92ee9b47839b Mon Sep 17 00:00:00 2001 From: Dennis Garding Date: Tue, 26 May 2026 07:11:27 +0200 Subject: [PATCH 5/6] Fix style upgrade.md --- UPGRADE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UPGRADE.md b/UPGRADE.md index f95c6c6b2..5e79a18f7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,7 +1,7 @@ # NEXT - [BREAKING] [#178](https://github.com/shopware/SwagMigrationAssistant/pull/190) - fix!: update product.sales during migration - - [BREAKING] Added required constructor parameter `\SwagMigrationAssistant\Migration\Service\ProductSalesUpdater` to `\SwagMigrationAssistant\Migration\Writer\OrderWriter` + - [BREAKING] Added required constructor parameter `\SwagMigrationAssistant\Migration\Service\ProductSalesUpdater` to `\SwagMigrationAssistant\Migration\Writer\OrderWriter` # 18.0.0 From fe8569f984d500a9b1eeeef573f9831d5e68a8c8 Mon Sep 17 00:00:00 2001 From: Dennis Garding Date: Tue, 26 May 2026 07:46:04 +0200 Subject: [PATCH 6/6] Fix phpstan issues --- src/Migration/Writer/OrderWriter.php | 17 ++++++--- .../Controller/HistoryControllerTest.php | 36 +++++++------------ .../Controller/StatusControllerTest.php | 3 +- .../ErrorResolution/MigrationFixTest.php | 3 +- .../Migration/History/HistoryServiceTest.php | 3 +- .../History/LogGroupingServiceTest.php | 3 +- 6 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/Migration/Writer/OrderWriter.php b/src/Migration/Writer/OrderWriter.php index f7d09aee2..46c53fae3 100644 --- a/src/Migration/Writer/OrderWriter.php +++ b/src/Migration/Writer/OrderWriter.php @@ -36,6 +36,8 @@ public function supports(): string public function writeData(array $data, Context $context): array { + // The migration payload still contains serialized transaction amounts. + // Convert them back to price structs before the DAL writes the orders. foreach ($data as &$item) { if (!isset($item['transactions']) || !\is_array($item['transactions'])) { continue; @@ -48,20 +50,27 @@ public function writeData(array $data, Context $context): array } unset($item); - // Re-migration case: product A is only visible before a line item switches to product B. + // Read the current products for the target orders before the upsert. + // In a re-migration, a line item can switch from product A to product B, + // so product A would no longer be reachable after the write. $orderIdsBeforeWrite = $this->extractOrderIdsFromPayload($data); $productIdsBeforeWrite = $this->productSalesUpdater->getProductIdsForOrders($orderIdsBeforeWrite); - // Write new orders or update already migrated orders. + // Let the regular writer create new orders or update already migrated orders. $result = parent::writeData($data, $context); - // Merge order IDs from payload and write result to also cover inserted orders. + // Collect all written order IDs. The payload IDs cover existing orders, + // while the DAL write result also confirms newly inserted orders. $writtenOrderIds = $this->extractOrderIdsFromWriteResults($result); $orderIds = $this->merge($orderIdsBeforeWrite, $writtenOrderIds); + + // Read the products again after the write to catch new or changed line items. $productIdsAfterWrite = $this->productSalesUpdater->getProductIdsForOrders($orderIds); $affectedProductIds = $this->merge($productIdsBeforeWrite, $productIdsAfterWrite); - // Recalculate every product that was affected before or after write. + // Recalculate the final sales value for every affected product. + // The updater derives the value from persisted line items, so repeated + // migration runs replace the sales value instead of incrementing it again. $this->productSalesUpdater->updateProducts($affectedProductIds); return $result; diff --git a/tests/Migration/Controller/HistoryControllerTest.php b/tests/Migration/Controller/HistoryControllerTest.php index e08c728b7..2dc01df09 100644 --- a/tests/Migration/Controller/HistoryControllerTest.php +++ b/tests/Migration/Controller/HistoryControllerTest.php @@ -120,8 +120,7 @@ public function testGetGroupedLogsOfRunWithoutUuid(): void { $request = new Request(); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runUuid" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runUuid')); $this->controller->getGroupedLogsOfRun($request, $this->context); } @@ -146,8 +145,7 @@ public function testDownloadLogsOfRunWithoutUuid(): void { $request = new Request(); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runUuid" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runUuid')); $this->controller->downloadLogsOfRun($request, $this->context); } @@ -171,8 +169,7 @@ public function testGetLogGroupsWithoutRunId(): void { $request = new Request(); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runId" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runId')); $this->controller->getLogGroups($request, $this->context); } @@ -181,8 +178,7 @@ public function testGetLogGroupsWithoutLevel(): void { $request = new Request(['runId' => $this->runUuid]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "level" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('level')); $this->controller->getLogGroups($request, $this->context); } @@ -375,8 +371,7 @@ public function testGetLogEntityIdsWithoutFixWithoutRunId(): void { $request = new Request([], []); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runId" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runId')); $this->controller->getLogEntityIdsWithoutFix($request); } @@ -385,8 +380,7 @@ public function testGetLogEntityIdsWithoutFixWithoutCode(): void { $request = new Request([], ['runId' => $this->runUuid]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "code" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('code')); $this->controller->getLogEntityIdsWithoutFix($request); } @@ -398,8 +392,7 @@ public function testGetLogEntityIdsWithoutFixWithoutEntityName(): void 'code' => 'TEST_CODE', ]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "entityName" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('entityName')); $this->controller->getLogEntityIdsWithoutFix($request); } @@ -412,8 +405,7 @@ public function testGetLogEntityIdsWithoutFixWithoutFieldName(): void 'entityName' => 'product', ]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "fieldName" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('fieldName')); $this->controller->getLogEntityIdsWithoutFix($request); } @@ -718,8 +710,7 @@ public function testGetUnresolvedLogsBatchInformationShouldThrowErrorWithoutRunI { $request = new Request([], []); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "runId" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('runId')); $this->controller->getUnresolvedLogsBatchInformation($request); } @@ -728,8 +719,7 @@ public function testGetUnresolvedLogsBatchInformationShouldThrowErrorWithoutCode { $request = new Request([], ['runId' => $this->runUuid]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "code" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('code')); $this->controller->getUnresolvedLogsBatchInformation($request); } @@ -741,8 +731,7 @@ public function testGetUnresolvedLogsBatchInformationShouldThrowErrorWithoutEnti 'code' => 'TEST_CODE', ]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "entityName" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('entityName')); $this->controller->getUnresolvedLogsBatchInformation($request); } @@ -755,8 +744,7 @@ public function testGetUnresolvedLogsBatchInformationShouldThrowErrorWithoutFiel 'entityName' => 'product', ]); - $this->expectException(RoutingException::class); - $this->expectExceptionMessage('Parameter "fieldName" is missing.'); + $this->expectExceptionObject(RoutingException::missingRequestParameter('fieldName')); $this->controller->getUnresolvedLogsBatchInformation($request); } diff --git a/tests/Migration/Controller/StatusControllerTest.php b/tests/Migration/Controller/StatusControllerTest.php index bf94b36cf..7fb7455f8 100644 --- a/tests/Migration/Controller/StatusControllerTest.php +++ b/tests/Migration/Controller/StatusControllerTest.php @@ -782,8 +782,7 @@ public function testAbortMigrationWithoutRunningMigration(): void $this->context ); - $this->expectException(MigrationException::class); - $this->expectExceptionMessage('No migration run found for run with id: "unknown".'); + $this->expectExceptionObject(MigrationException::runNotFound()); $this->controller->abortMigration($this->context); } diff --git a/tests/unit/Migration/ErrorResolution/MigrationFixTest.php b/tests/unit/Migration/ErrorResolution/MigrationFixTest.php index cf5599734..1d103b0d6 100644 --- a/tests/unit/Migration/ErrorResolution/MigrationFixTest.php +++ b/tests/unit/Migration/ErrorResolution/MigrationFixTest.php @@ -283,8 +283,7 @@ public function testCreateFromDatabaseQuery(): void #[DataProvider('dataWithMissingKeys')] public function testCreateFromDatabaseQueryWithErrors(array $data, string $expectedMissingKey): void { - $this->expectException(MigrationException::class); - $this->expectExceptionMessage(\sprintf('Missing key "%s" to construct MigrationFix.', $expectedMissingKey)); + $this->expectExceptionObject(MigrationException::couldNotConvertFix($expectedMissingKey)); MigrationFix::fromDatabaseQuery($data); } diff --git a/tests/unit/Migration/History/HistoryServiceTest.php b/tests/unit/Migration/History/HistoryServiceTest.php index 3dfe00fe2..6753f2922 100644 --- a/tests/unit/Migration/History/HistoryServiceTest.php +++ b/tests/unit/Migration/History/HistoryServiceTest.php @@ -46,8 +46,7 @@ class HistoryServiceTest extends TestCase { public function testShouldThrowIfRunCantBeFound(): void { - static::expectException(MigrationException::class); - static::expectExceptionMessage('No SwagMigrationAssistant\Migration\Run\SwagMigrationRunEntity with UUID run-id found. Make sure the entity with the UUID exists.'); + static::expectExceptionObject(MigrationException::entityNotExists(SwagMigrationRunEntity::class, 'run-id')); $this->createHistoryService([], [])->downloadLogsOfRun( 'run-id', diff --git a/tests/unit/Migration/History/LogGroupingServiceTest.php b/tests/unit/Migration/History/LogGroupingServiceTest.php index cb1efee85..586b2f207 100644 --- a/tests/unit/Migration/History/LogGroupingServiceTest.php +++ b/tests/unit/Migration/History/LogGroupingServiceTest.php @@ -39,8 +39,7 @@ public function testGetGroupedLogsByCodeAndEntityThrowsExceptionWhenConnectionNo { $this->connection->method('fetchOne')->willReturn(false); - static::expectException(MigrationException::class); - static::expectExceptionMessage('No connection found.'); + static::expectExceptionObject(MigrationException::noConnectionFound()); $this->logGroupingService->getGroupedLogsByCodeAndEntity( Uuid::randomHex(),