diff --git a/config/admin/services.yml b/config/admin/services.yml index ed59ebbf..39d2646c 100644 --- a/config/admin/services.yml +++ b/config/admin/services.yml @@ -33,6 +33,12 @@ services: tags: - { name: 'console.command' } + # Custom normalizers for commands/queries that are very specific and cannot rely on generic solutions from the core + PrestaShop\Module\APIResources\ApiPlatform\Normalizer\GenerateCombinationsSerializer: + autowire: true + autoconfigure: true + public: false + PrestaShop\Module\APIResources\ApiPlatform\Normalizer\StateIdInterfaceNormalizer: tags: - { name: 'serializer.normalizer', priority: 100 } @@ -54,8 +60,6 @@ services: public: false arguments: $decorated: '@serializer.normalizer.object' - tags: - - { name: 'serializer.normalizer', priority: 300 } PrestaShopBundle\ApiPlatform\Serializer\CQRSApiSerializer: class: PrestaShop\Module\APIResources\Serializer\QueryParameterTypeCastSerializer diff --git a/ps_apiresources.php b/ps_apiresources.php index 7aae2a9a..3f83ca72 100644 --- a/ps_apiresources.php +++ b/ps_apiresources.php @@ -37,7 +37,7 @@ public function __construct() $this->description = $this->trans('Includes the resources allowing using the API for the PrestaShop domain, all endpoints are based on CQRS commands/queries from the Core and we APIPlatform framework is used as a base.', [], 'Modules.Apiresources.Admin'); $this->author = 'PrestaShop'; $this->version = '0.3.0'; - $this->ps_versions_compliancy = ['min' => '9.0.2', 'max' => _PS_VERSION_]; + $this->ps_versions_compliancy = ['min' => '9.0.3', 'max' => _PS_VERSION_]; $this->need_instance = 0; $this->tab = 'administration'; parent::__construct(); diff --git a/src/ApiPlatform/Normalizer/GenerateCombinationsSerializer.php b/src/ApiPlatform/Normalizer/GenerateCombinationsSerializer.php new file mode 100644 index 00000000..785b2f01 --- /dev/null +++ b/src/ApiPlatform/Normalizer/GenerateCombinationsSerializer.php @@ -0,0 +1,64 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Normalizer; + +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Command\GenerateProductCombinationsCommand; +use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; +use PrestaShopBundle\ApiPlatform\Normalizer\ShopConstraintNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class GenerateCombinationsSerializer implements DenormalizerInterface +{ + public function __construct( + private readonly ShopConstraintNormalizer $shopConstraintNormalizer, + ) { + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []) + { + $groupedAttributes = []; + foreach ($data['groupedAttributes'] as $attributeGroup) { + $groupedAttributes[$attributeGroup['attributeGroupId']] = array_map(static function ($attributeId): int { + return (int) $attributeId; + }, $attributeGroup['attributeIds']); + } + + return new GenerateProductCombinationsCommand( + $data['productId'], + $groupedAttributes, + $this->shopConstraintNormalizer->denormalize($data['_context']['shopConstraint'], ShopConstraint::class), + ); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null) + { + return $type === GenerateProductCombinationsCommand::class; + } + + public function getSupportedTypes(?string $format): array + { + return [ + GenerateProductCombinationsCommand::class => true, + 'object' => null, + '*' => null, + ]; + } +} diff --git a/src/ApiPlatform/Resources/Product/Combination.php b/src/ApiPlatform/Resources/Product/Combination.php new file mode 100644 index 00000000..07435b82 --- /dev/null +++ b/src/ApiPlatform/Resources/Product/Combination.php @@ -0,0 +1,101 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Exception\CombinationNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Query\GetCombinationForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/products/combinations/{combinationId}', + CQRSQuery: GetCombinationForEditing::class, + scopes: [ + 'product_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + ], + exceptionToStatus: [ + CombinationNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Combination +{ + public int $productId; + + #[ApiProperty(identifier: true)] + public int $combinationId; + public string $name; + public bool $default; + + public string $gtin; + public string $isbn; + public string $mpn; + public string $reference; + public string $upc; + + public string $coverThumbnailUrl; + #[ApiProperty(openapiContext: ['type' => 'array', 'description' => 'List of image IDs', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + public array $imageIds; + + public DecimalNumber $impactOnPriceTaxExcluded; + public DecimalNumber $impactOnPriceTaxIncluded; + public DecimalNumber $impactOnUnitPrice; + public DecimalNumber $impactOnUnitPriceTaxIncluded; + public DecimalNumber $ecotaxTaxExcluded; + public DecimalNumber $ecotaxTaxIncluded; + public DecimalNumber $impactOnWeight; + public DecimalNumber $wholesalePrice; + public DecimalNumber $productTaxRate; + public DecimalNumber $productPriceTaxExcluded; + public DecimalNumber $productEcotaxTaxExcluded; + + public int $quantity; + + public const QUERY_MAPPING = [ + '[_context][shopConstraint]' => '[shopConstraint]', + '[details][gtin]' => '[gtin]', + '[details][isbn]' => '[isbn]', + '[details][mpn]' => '[mpn]', + '[details][reference]' => '[reference]', + '[details][upc]' => '[upc]', + '[details][impactOnWeight]' => '[impactOnWeight]', + '[prices][impactOnPrice]' => '[impactOnPriceTaxExcluded]', + '[prices][impactOnPriceTaxIncluded]' => '[impactOnPriceTaxIncluded]', + '[prices][impactOnUnitPrice]' => '[impactOnUnitPriceTaxExcluded]', + '[prices][impactOnUnitPriceTaxIncluded]' => '[impactOnUnitPriceTaxIncluded]', + '[prices][ecotax]' => '[ecotaxTaxExcluded]', + '[prices][ecotaxTaxIncluded]' => '[ecotaxTaxIncluded]', + '[prices][wholesalePrice]' => '[wholesalePrice]', + '[prices][productTaxRate]' => '[productTaxRate]', + '[prices][productPrice]' => '[productPriceTaxExcluded]', + '[prices][productEcotax]' => '[productEcotaxTaxExcluded]', + '[stock][quantity]' => '[quantity]', + ]; +} diff --git a/src/ApiPlatform/Resources/Product/CombinationIdList.php b/src/ApiPlatform/Resources/Product/CombinationIdList.php new file mode 100644 index 00000000..b703f289 --- /dev/null +++ b/src/ApiPlatform/Resources/Product/CombinationIdList.php @@ -0,0 +1,55 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Query\GetCombinationIds; +use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/products/{productId}/combination-ids', + CQRSQuery: GetCombinationIds::class, + scopes: [ + 'product_read', + ], + CQRSQueryMapping: [ + '[_context][shopConstraint]' => '[shopConstraint]', + '[@index][combinationId]' => '[combinationIds][@index]', + ], + ), + ], + exceptionToStatus: [ + ProductNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CombinationIdList +{ + public int $productId; + #[ApiProperty(openapiContext: ['type' => 'array', 'description' => 'List of combination IDs', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + public array $combinationIds; +} diff --git a/src/ApiPlatform/Resources/Product/CombinationList.php b/src/ApiPlatform/Resources/Product/CombinationList.php new file mode 100644 index 00000000..8b3604f5 --- /dev/null +++ b/src/ApiPlatform/Resources/Product/CombinationList.php @@ -0,0 +1,88 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Query\GetEditableCombinationsList; +use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; +use PrestaShop\PrestaShop\Core\Search\Filters\ProductCombinationFilters; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPaginate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSPaginate( + uriTemplate: '/products/{productId}/combinations', + CQRSQuery: GetEditableCombinationsList::class, + scopes: [ + 'product_read', + ], + CQRSQueryMapping: [ + '[_context][langId]' => '[languageId]', + '[_context][shopConstraint]' => '[shopConstraint]', + ], + ApiResourceMapping: [ + '[combinationName]' => '[name]', + '[attributesInformation]' => '[attributes]', + '[impactOnPrice]' => '[impactOnPriceTaxExcluded]', + ], + filtersClass: ProductCombinationFilters::class, + filtersMapping: [ + '[_context][shopId]' => '[shopId]', + ], + itemsField: 'combinations', + countField: 'totalCombinationsCount', + ), + ], + exceptionToStatus: [ + ProductNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CombinationList +{ + public int $productId; + public int $combinationId; + public string $name; + public bool $default; + public string $reference; + public DecimalNumber $impactOnPriceTaxExcluded; + public DecimalNumber $ecoTax; + public int $quantity; + public string $imageUrl; + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'Combination attributes', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'attributeGroupId' => ['type' => 'integer'], + 'attributeGroupName' => ['type' => 'string'], + 'attributeId' => ['type' => 'integer'], + 'attributeName' => ['type' => 'string'], + ], + ], + ] + )] + public array $attributes; +} diff --git a/src/ApiPlatform/Resources/Product/GenerateCombinations.php b/src/ApiPlatform/Resources/Product/GenerateCombinations.php new file mode 100644 index 00000000..0e9ac387 --- /dev/null +++ b/src/ApiPlatform/Resources/Product/GenerateCombinations.php @@ -0,0 +1,86 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Product; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Product\Combination\Command\GenerateProductCombinationsCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSCreate( + uriTemplate: '/products/{productId}/generate-combinations', + CQRSCommand: GenerateProductCombinationsCommand::class, + scopes: [ + 'product_write', + ], + ApiResourceMapping: [ + // Used to denormalize the command result + '[@index][combinationId]' => '[newCombinationIds][@index]', + ], + ), + ], + exceptionToStatus: [ + ProductNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class GenerateCombinations +{ + public int $productId; + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'description' => 'List of new generated combination IDs', + 'items' => [ + 'type' => 'integer', + 'description' => 'Combination ID', + ], + ] + )] + public array $newCombinationIds = []; + + #[ApiProperty( + openapiContext: [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'description' => 'List of attributes grouped by their attribute group', + 'properties' => [ + 'attributeGroupId' => [ + 'type' => 'number', + 'description' => 'Attribute group ID', + ], + 'attributeIds' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'integer', + 'description' => 'Attribute ID', + ], + ], + ], + ], + ], + )] + public array $groupedAttributes; +} diff --git a/src/ApiPlatform/Resources/Product/NewProductImage.php b/src/ApiPlatform/Resources/Product/NewProductImage.php index 6be8a379..fe2e4e96 100644 --- a/src/ApiPlatform/Resources/Product/NewProductImage.php +++ b/src/ApiPlatform/Resources/Product/NewProductImage.php @@ -36,6 +36,7 @@ new CQRSCreate( uriTemplate: '/products/{productId}/images', inputFormats: ['multipart' => ['multipart/form-data']], + requirements: ['productId' => '\d+'], read: false, CQRSCommand: AddProductImageCommand::class, CQRSQuery: GetProductImage::class, @@ -56,7 +57,6 @@ class NewProductImage { public int $productId; - public int $imageId; public string $imageUrl; diff --git a/tests/Integration/ApiPlatform/AddressEndpointTest.php b/tests/Integration/ApiPlatform/AddressEndpointTest.php index 7051860b..846094dd 100644 --- a/tests/Integration/ApiPlatform/AddressEndpointTest.php +++ b/tests/Integration/ApiPlatform/AddressEndpointTest.php @@ -230,6 +230,17 @@ public function testUpdateOrderAddress(): void ]; $customerAddress = $this->createItem('/addresses/customers', $addressData, ['address_write']); $addressId = $customerAddress['addressId']; + $expectedAddress = [ + 'addressId' => $addressId, + 'address2' => '', + 'dni' => '', + 'company' => '', + 'vatNumber' => '', + 'homePhone' => '', + 'mobilePhone' => '', + 'other' => '', + ] + $addressData; + $this->assertEquals($expectedAddress, $customerAddress); // Create a minimal order with this address $order = new \Order(); diff --git a/tests/Integration/ApiPlatform/ProductCombinationEndpointTest.php b/tests/Integration/ApiPlatform/ProductCombinationEndpointTest.php new file mode 100644 index 00000000..8b290ce9 --- /dev/null +++ b/tests/Integration/ApiPlatform/ProductCombinationEndpointTest.php @@ -0,0 +1,355 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use PrestaShop\PrestaShop\Adapter\Attribute\Repository\AttributeRepository; +use PrestaShop\PrestaShop\Adapter\AttributeGroup\Repository\AttributeGroupRepository; +use PrestaShop\PrestaShop\Core\Domain\AttributeGroup\ValueObject\AttributeGroupId; +use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductType; +use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; +use Tests\Resources\Resetter\LanguageResetter; +use Tests\Resources\Resetter\ProductResetter; +use Tests\Resources\ResourceResetter; + +class ProductCombinationEndpointTest extends ApiTestCase +{ + /** + * @var array + */ + private static array $attributeGroupData = []; + /** + * @var array + */ + private static array $attributeData = []; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + (new ResourceResetter())->backupTestModules(); + ProductResetter::resetProducts(); + LanguageResetter::resetLanguages(); + self::addLanguageByLocale('fr-FR'); + // Pre-create the API Client with the needed scopes, this way we reduce the number of created API Clients + self::createApiClient(['product_write', 'product_read']); + + // Fetch data for attributes to use them more easily in the following tests + /** @var AttributeGroupRepository $attributeGroupRepository */ + $attributeGroupRepository = self::getContainer()->get(AttributeGroupRepository::class); + $attributeGroups = $attributeGroupRepository->getAttributeGroups(ShopConstraint::allShops()); + foreach ($attributeGroups as $attributeGroup) { + // Store english name as the key + self::$attributeGroupData[$attributeGroup->name[1]] = (int) $attributeGroup->id; + } + + /** @var AttributeRepository $attributeRepository */ + $attributeRepository = self::getContainer()->get(AttributeRepository::class); + $attributeGroupIds = array_map(static function (int $attributeGroupId) { + return new AttributeGroupId($attributeGroupId); + }, array_values(self::$attributeGroupData)); + $groupedAttributes = $attributeRepository->getGroupedAttributes(ShopConstraint::allShops(), $attributeGroupIds); + foreach ($groupedAttributes as $attributeGroupId => $groupAttributes) { + foreach ($groupAttributes as $attribute) { + self::$attributeData[$attribute->name[1]] = (int) $attribute->id; + } + } + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + ProductResetter::resetProducts(); + LanguageResetter::resetLanguages(); + // Reset modules folder that are removed with the FR language + (new ResourceResetter())->resetTestModules(); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'generate combinations endpoint' => [ + 'POST', + '/products/1/generate-combinations', + ]; + + yield 'list combination IDs' => [ + 'GET', + '/products/1/combination-ids', + ]; + + yield 'get endpoint' => [ + 'GET', + '/products/combinations/1', + ]; + } + + public function testAddProductWithCombinations(): int + { + $addedProduct = $this->createItem('/products', [ + 'type' => ProductType::TYPE_COMBINATIONS, + 'names' => [ + 'en-US' => 'product with combinations', + 'fr-FR' => 'produit avec combinaisons', + ], + ], ['product_write']); + + return $addedProduct['productId']; + } + + /** + * @depends testAddProductWithCombinations + */ + public function testCreateProductCombinations(int $productId): array + { + $postData = [ + 'groupedAttributes' => [ + [ + 'attributeGroupId' => self::$attributeGroupData['Color'], + 'attributeIds' => [ + self::$attributeData['Red'], + self::$attributeData['Black'], + self::$attributeData['Yellow'], + ], + ], + [ + 'attributeGroupId' => self::$attributeGroupData['Size'], + 'attributeIds' => [ + self::$attributeData['S'], + self::$attributeData['M'], + self::$attributeData['L'], + ], + ], + ], + ]; + $createdCombinations = $this->createItem(sprintf('/products/%d/generate-combinations', $productId), $postData, ['product_write']); + // 9 combinations should have been created (three sizes for each three colors) + $this->assertCount(9, $createdCombinations['newCombinationIds']); + $newCombinationIds = $createdCombinations['newCombinationIds']; + foreach ($newCombinationIds as $combinationId) { + $this->assertIsInt($combinationId); + } + $expectedResult = [ + 'productId' => $productId, + // We fill this value dynamically because we can't guess their values + 'newCombinationIds' => $newCombinationIds, + ]; + $this->assertEquals($expectedResult, $createdCombinations); + + // Now we call the same endpoint with the same attributes + $createdCombinations = $this->createItem(sprintf('/products/%d/generate-combinations', $productId), $postData, ['product_write']); + $this->assertEquals([ + 'productId' => $productId, + // Since the combinations already exist no new combination is created + 'newCombinationIds' => [], + ], $createdCombinations); + + // Now we call with extra attributes, only the missing combinations are created + $postData['groupedAttributes'][0]['attributeIds'][] = self::$attributeData['White']; + $postData['groupedAttributes'][1]['attributeIds'][] = self::$attributeData['XL']; + + // In total 16 combinations should be created, 9 have already been so 7 new combinations should be returned + $createdCombinations = $this->createItem(sprintf('/products/%d/generate-combinations', $productId), $postData, ['product_write']); + $this->assertCount(7, $createdCombinations['newCombinationIds']); + $newCombinationIds = array_merge($newCombinationIds, $createdCombinations['newCombinationIds']); + $this->assertCount(16, $newCombinationIds); + + // Now create new combinations from other attributes (we don't merge with previous postData) + $postData = [ + 'groupedAttributes' => [ + [ + 'attributeGroupId' => self::$attributeGroupData['Color'], + 'attributeIds' => [ + self::$attributeData['Blue'], + ], + ], + [ + 'attributeGroupId' => self::$attributeGroupData['Size'], + 'attributeIds' => [ + self::$attributeData['M'], + self::$attributeData['L'], + ], + ], + ], + ]; + $createdCombinations = $this->createItem(sprintf('/products/%d/generate-combinations', $productId), $postData, ['product_write']); + // Only two new combinations should have been created + $this->assertCount(2, $createdCombinations['newCombinationIds']); + $newCombinationIds = array_merge($newCombinationIds, $createdCombinations['newCombinationIds']); + $this->assertCount(18, $newCombinationIds); + + return $newCombinationIds; + } + + /** + * @depends testAddProductWithCombinations + * @depends testCreateProductCombinations + */ + public function testListCombinationsIds(int $productId, array $newCombinationIds): array + { + $combinations = $this->getItem(sprintf('/products/%d/combination-ids', $productId), ['product_read']); + $this->assertEquals([ + 'productId' => $productId, + 'combinationIds' => $newCombinationIds, + ], $combinations); + + // Now test pagination + $resultsPerPage = 5; + $pagesNumber = ceil(count($newCombinationIds) / $resultsPerPage); + for ($page = 1; $page <= $pagesNumber; ++$page) { + $offset = ($page - 1) * $resultsPerPage; + $paginatedCombinations = $this->getItem(sprintf( + '/products/%d/combination-ids?offset=%d&limit=%d', + $productId, + $offset, + $resultsPerPage), + ['product_read'] + ); + $expectedCombinationIds = array_slice($newCombinationIds, $offset, $resultsPerPage); + $this->assertEquals([ + 'productId' => $productId, + 'combinationIds' => $expectedCombinationIds, + ], $paginatedCombinations); + } + + return $newCombinationIds; + } + + /** + * @depends testAddProductWithCombinations + * @depends testCreateProductCombinations + */ + public function testCombinationList(int $productId, array $newCombinationIds): array + { + $paginatedCombinations = $this->listItems(sprintf('/products/%d/combinations', $productId), ['product_read']); + $this->assertEquals(count($newCombinationIds), $paginatedCombinations['totalItems']); + + // Now check the expected format at least for the first two + $this->assertEquals([ + 'productId' => $productId, + 'combinationId' => $newCombinationIds[0], + 'name' => 'Size - S, Color - Red', + 'default' => true, + 'reference' => '', + 'impactOnPriceTaxExcluded' => 0.0, + 'ecoTax' => 0.0, + 'quantity' => 0, + 'imageUrl' => 'http://myshop.com/img/p/en-default-small_default.jpg', + 'attributes' => [ + [ + 'attributeGroupId' => self::$attributeGroupData['Size'], + 'attributeGroupName' => 'Size', + 'attributeId' => self::$attributeData['S'], + 'attributeName' => 'S', + ], + [ + 'attributeGroupId' => self::$attributeGroupData['Color'], + 'attributeGroupName' => 'Color', + 'attributeId' => self::$attributeData['Red'], + 'attributeName' => 'Red', + ], + ], + ], $paginatedCombinations['items'][0]); + $this->assertEquals([ + 'productId' => $productId, + 'combinationId' => $newCombinationIds[1], + 'name' => 'Size - M, Color - Red', + 'default' => false, + 'reference' => '', + 'impactOnPriceTaxExcluded' => 0.0, + 'ecoTax' => 0.0, + 'quantity' => 0, + 'imageUrl' => 'http://myshop.com/img/p/en-default-small_default.jpg', + 'attributes' => [ + [ + 'attributeGroupId' => self::$attributeGroupData['Size'], + 'attributeGroupName' => 'Size', + 'attributeId' => self::$attributeData['M'], + 'attributeName' => 'M', + ], + [ + 'attributeGroupId' => self::$attributeGroupData['Color'], + 'attributeGroupName' => 'Color', + 'attributeId' => self::$attributeData['Red'], + 'attributeName' => 'Red', + ], + ], + ], $paginatedCombinations['items'][1]); + + // Now test pagination + $resultsPerPage = 5; + $pagesNumber = ceil(count($newCombinationIds) / $resultsPerPage); + for ($page = 1; $page <= $pagesNumber; ++$page) { + $offset = ($page - 1) * $resultsPerPage; + $paginatedCombinations = $this->getItem(sprintf( + '/products/%d/combinations?offset=%d&limit=%d', + $productId, + $offset, + $resultsPerPage), + ['product_read'] + ); + $expectedCombinationIds = array_slice($newCombinationIds, $offset, $resultsPerPage); + $paginatedCombinationIds = array_map(static function (array $combination): int { + return $combination['combinationId']; + }, $paginatedCombinations['items']); + $this->assertEquals($expectedCombinationIds, $paginatedCombinationIds); + } + + return $newCombinationIds; + } + + /** + * @depends testAddProductWithCombinations + * @depends testListCombinationsIds + * + * @return int + */ + public function testGetProductCombination(int $productId, array $newCombinationIds): int + { + $combinationId = $newCombinationIds[0]; + $combination = $this->getItem('/products/combinations/' . $combinationId, ['product_read']); + $this->assertEquals([ + 'productId' => $productId, + 'combinationId' => $combinationId, + 'name' => 'Size - S, Color - Red', + 'default' => true, + 'gtin' => '', + 'isbn' => '', + 'mpn' => '', + 'reference' => '', + 'upc' => '', + 'coverThumbnailUrl' => 'http://myshop.com/img/p/en-default-cart_default.jpg', + 'imageIds' => [], + 'impactOnPriceTaxExcluded' => 0.0, + 'impactOnPriceTaxIncluded' => 0.0, + 'impactOnUnitPriceTaxIncluded' => 0.0, + 'ecotaxTaxExcluded' => 0.0, + 'ecotaxTaxIncluded' => 0.0, + 'impactOnWeight' => 0.0, + 'wholesalePrice' => 0.0, + 'productTaxRate' => 6.0, + 'productPriceTaxExcluded' => 0.0, + 'productEcotaxTaxExcluded' => 0.0, + 'quantity' => 0, + ], $combination); + + return $combinationId; + } +} diff --git a/tests/Integration/ApiPlatform/ProductEndpointTest.php b/tests/Integration/ApiPlatform/ProductEndpointTest.php index c224a985..ca91efe9 100644 --- a/tests/Integration/ApiPlatform/ProductEndpointTest.php +++ b/tests/Integration/ApiPlatform/ProductEndpointTest.php @@ -507,6 +507,7 @@ public function testAddImage(int $productId): int // Check URLs format based on the newly created Image ID $expectedImage = [ + 'productId' => $productId, 'imageId' => $imageId, 'imageUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($imageId, false), 'thumbnailUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($imageId, true), @@ -635,6 +636,7 @@ public function testListImages(int $productId, int $imageId): void $this->assertEquals(2, count($productImages)); $this->assertEquals([ [ + 'productId' => $productId, 'imageId' => $imageId, 'imageUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($imageId, false), 'thumbnailUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($imageId, true), @@ -649,6 +651,7 @@ public function testListImages(int $productId, int $imageId): void ], ], [ + 'productId' => $productId, 'imageId' => $newImageId, 'imageUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($newImageId, false), 'thumbnailUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($newImageId, true), @@ -686,6 +689,7 @@ public function testListImages(int $productId, int $imageId): void // The images are sorted differently (since they are automatically order by position) and the cover has been updated $this->assertEquals([ [ + 'productId' => $productId, 'imageId' => $newImageId, 'imageUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($newImageId, false), 'thumbnailUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($newImageId, true), @@ -700,6 +704,7 @@ public function testListImages(int $productId, int $imageId): void ], ], [ + 'productId' => $productId, 'imageId' => $imageId, 'imageUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($imageId, false), 'thumbnailUrl' => 'http://myshop.com/img/p/' . $this->getImagePath($imageId, true),