From b9d8d1225c2b1910a8df7c5e08b3b75e6c6c505c Mon Sep 17 00:00:00 2001 From: tleon Date: Tue, 23 Jan 2024 16:22:10 +0100 Subject: [PATCH] chore(ci): move tests and endpoint from core --- src/ApiPlatform/Resources/CartRule.php | 76 ++++ src/ApiPlatform/Resources/CustomerGroup.php | 119 +++++++ src/ApiPlatform/Resources/FoundProduct.php | 108 ++++++ src/ApiPlatform/Resources/Product.php | 115 ++++++ tests/Integration/ApiPlatform/ApiTestCase.php | 83 +++++ .../ApiPlatform/CustomerGroupApiTest.php | 239 +++++++++++++ .../ApiPlatform/DomainSerializerTest.php | 335 ++++++++++++++++++ .../ApiPlatform/ProductEndpointTest.php | 302 ++++++++++++++++ .../ProductMultiShopEndpointTest.php | 217 ++++++++++++ tests/Resources/assets/lang/en.jpg | Bin 0 -> 6249 bytes tests/Resources/assets/lang/fr.jpg | Bin 0 -> 7862 bytes 11 files changed, 1594 insertions(+) create mode 100644 src/ApiPlatform/Resources/CartRule.php create mode 100644 src/ApiPlatform/Resources/CustomerGroup.php create mode 100644 src/ApiPlatform/Resources/FoundProduct.php create mode 100644 src/ApiPlatform/Resources/Product.php create mode 100644 tests/Integration/ApiPlatform/CustomerGroupApiTest.php create mode 100644 tests/Integration/ApiPlatform/DomainSerializerTest.php create mode 100644 tests/Integration/ApiPlatform/ProductEndpointTest.php create mode 100644 tests/Integration/ApiPlatform/ProductMultiShopEndpointTest.php create mode 100644 tests/Resources/assets/lang/en.jpg create mode 100644 tests/Resources/assets/lang/fr.jpg diff --git a/src/ApiPlatform/Resources/CartRule.php b/src/ApiPlatform/Resources/CartRule.php new file mode 100644 index 0000000..ab54b38 --- /dev/null +++ b/src/ApiPlatform/Resources/CartRule.php @@ -0,0 +1,76 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Put; +use PrestaShop\PrestaShop\Core\Domain\CartRule\Command\EditCartRuleCommand; +use PrestaShopBundle\ApiPlatform\Processor\CommandProcessor; + +#[ApiResource( + operations: [ + new Put( + uriTemplate: '/cart-rule', + processor: CommandProcessor::class, + extraProperties: ['CQRSCommand' => EditCartRuleCommand::class] + ), + ], +)] +class CartRule +{ + public int $cartRuleId; + + public string $description; + + public string $code; + + public array $minimumAmount; + + public bool $minimumAmountShippingIncluded; + + public int $customerId; + + public array $localizedNames; + + public bool $highlightInCart; + + public bool $allowPartialUse; + + public int $priority; + + public bool $active; + + public array $validityDateRange; + + public int $totalQuantity; + + public int $quantityPerUser; + + public array $cartRuleAction; +} diff --git a/src/ApiPlatform/Resources/CustomerGroup.php b/src/ApiPlatform/Resources/CustomerGroup.php new file mode 100644 index 0000000..7a06c28 --- /dev/null +++ b/src/ApiPlatform/Resources/CustomerGroup.php @@ -0,0 +1,119 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Command\AddCustomerGroupCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Command\DeleteCustomerGroupCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Command\EditCustomerGroupCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Exception\GroupNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Query\GetCustomerGroupForEditing; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\QueryResult\EditableCustomerGroup; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/customers/group/{customerGroupId}', + CQRSQuery: GetCustomerGroupForEditing::class, + scopes: [ + 'customer_group_read', + ], + // QueryResult format doesn't match with ApiResource, so we can specify a mapping so that it is normalized with extra fields adapted for the ApiResource DTO + CQRSQueryMapping: [ + // EditableCustomerGroup::$id is normalized as [customerGroupId] + '[id]' => '[customerGroupId]', + // EditableCustomerGroup::$reduction is normalized as [reductionPercent] + '[reduction]' => '[reductionPercent]', + ], + ), + new CQRSCreate( + uriTemplate: '/customers/group', + CQRSCommand: AddCustomerGroupCommand::class, + CQRSQuery: GetCustomerGroupForEditing::class, + scopes: [ + 'customer_group_write', + ], + // Here, we use query mapping to adapt normalized query result for the ApiPlatform DTO + CQRSQueryMapping: [ + '[id]' => '[customerGroupId]', + '[reduction]' => '[reductionPercent]', + ], + // Here, we use command mapping to adapt the normalized command result for the CQRS query + CQRSCommandMapping: [ + '[groupId]' => '[customerGroupId]', + ], + ), + new CQRSUpdate( + uriTemplate: '/customers/group/{customerGroupId}', + CQRSCommand: EditCustomerGroupCommand::class, + CQRSQuery: GetCustomerGroupForEditing::class, + scopes: [ + 'customer_group_write', + ], + // Here we use the ApiResource DTO mapping to transform the normalized query result + ApiResourceMapping: [ + '[id]' => '[customerGroupId]', + '[reduction]' => '[reductionPercent]', + ], + ), + new CQRSDelete( + uriTemplate: '/customers/group/{customerGroupId}', + CQRSQuery: DeleteCustomerGroupCommand::class, + scopes: [ + 'customer_group_write', + ], + // Here, we use query mapping to adapt URI parameters to the expected constructor parameter name + CQRSQueryMapping: [ + '[customerGroupId]' => '[groupId]', + ], + ), + ], + exceptionToStatus: [GroupNotFoundException::class => 404], +)] +class CustomerGroup +{ + #[ApiProperty(identifier: true)] + public int $customerGroupId; + + public array $localizedNames; + + public float $reductionPercent; + + public bool $displayPriceTaxExcluded; + + public bool $showPrice; + + public array $shopIds; +} diff --git a/src/ApiPlatform/Resources/FoundProduct.php b/src/ApiPlatform/Resources/FoundProduct.php new file mode 100644 index 0000000..55ad24e --- /dev/null +++ b/src/ApiPlatform/Resources/FoundProduct.php @@ -0,0 +1,108 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use PrestaShop\PrestaShop\Core\Domain\Product\Query\SearchProducts; +use PrestaShopBundle\ApiPlatform\Provider\QueryProvider; + +#[ApiResource( + operations: [ + new GetCollection( + uriTemplate: '/products/search/{phrase}/{resultsLimit}/{isoCode}', + openapiContext: [ + 'parameters' => [ + [ + 'name' => 'phrase', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string', + ], + ], + [ + 'name' => 'resultsLimit', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'int', + ], + ], + [ + 'name' => 'isoCode', + 'in' => 'path', + 'required' => true, + 'schema' => [ + 'type' => 'string', + ], + ], + [ + 'name' => 'orderId', + 'in' => 'query', + 'required' => false, + 'schema' => [ + 'type' => 'int', + ], + ], + ], + ], + provider: QueryProvider::class, + extraProperties: [ + 'CQRSQuery' => SearchProducts::class, + ] + ), + ], +)] +class FoundProduct +{ + #[ApiProperty(identifier: true)] + public int $productId; + + public bool $availableOutOfStock; + + public string $name; + + public float $taxRate; + + public string $formattedPrice; + + public float $priceTaxIncl; + + public float $priceTaxExcl; + + public int $stock; + + public string $location; + + public array $combinations; + + public array $customizationFields; +} diff --git a/src/ApiPlatform/Resources/Product.php b/src/ApiPlatform/Resources/Product.php new file mode 100644 index 0000000..0337b22 --- /dev/null +++ b/src/ApiPlatform/Resources/Product.php @@ -0,0 +1,115 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Product\Command\AddProductCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Command\DeleteProductCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Command\UpdateProductCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Exception\ProductNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Product\Query\GetProductForEditing; +use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\ShopAssociationNotFound; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/product/{productId}', + CQRSQuery: GetProductForEditing::class, + scopes: [ + 'product_read', + ], + CQRSQueryMapping: Product::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/product', + CQRSCommand: AddProductCommand::class, + CQRSQuery: GetProductForEditing::class, + scopes: [ + 'product_write', + ], + CQRSQueryMapping: Product::QUERY_MAPPING, + CQRSCommandMapping: [ + '[type]' => '[productType]', + '[names]' => '[localizedNames]', + ], + ), + new CQRSPartialUpdate( + uriTemplate: '/product/{productId}', + CQRSCommand: UpdateProductCommand::class, + CQRSQuery: GetProductForEditing::class, + scopes: [ + 'product_write', + ], + CQRSQueryMapping: Product::QUERY_MAPPING, + CQRSCommandMapping: Product::UPDATE_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/product/{productId}', + CQRSQuery: DeleteProductCommand::class, + scopes: [ + 'product_write', + ], + ), + ], + exceptionToStatus: [ + ProductNotFoundException::class => Response::HTTP_NOT_FOUND, + ShopAssociationNotFound::class => Response::HTTP_NOT_FOUND, + ], +)] +class Product +{ + #[ApiProperty(identifier: true)] + public int $productId; + + public string $type; + + public bool $active; + + public array $names; + + public array $descriptions; + + public const QUERY_MAPPING = [ + '[langId]' => '[displayLanguageId]', + '[basicInformation][localizedNames]' => '[names]', + '[basicInformation][localizedDescriptions]' => '[descriptions]', + ]; + + public const UPDATE_MAPPING = [ + '[type]' => '[productType]', + '[names]' => '[localizedNames]', + '[descriptions]' => '[localizedDescriptions]', + ]; +} diff --git a/tests/Integration/ApiPlatform/ApiTestCase.php b/tests/Integration/ApiPlatform/ApiTestCase.php index cb55392..4378e84 100644 --- a/tests/Integration/ApiPlatform/ApiTestCase.php +++ b/tests/Integration/ApiPlatform/ApiTestCase.php @@ -31,6 +31,9 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase as SymfonyApiTestCase; use ApiPlatform\Symfony\Bundle\Test\Client; use PrestaShop\PrestaShop\Core\Domain\ApiAccess\Command\AddApiAccessCommand; +use PrestaShop\PrestaShop\Core\Domain\Configuration\ShopConfigurationInterface; +use PrestaShop\PrestaShop\Core\Domain\Language\Command\AddLanguageCommand; +use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; use Tests\Resources\DatabaseDump; abstract class ApiTestCase extends SymfonyApiTestCase @@ -98,4 +101,84 @@ protected static function createApiAccess(array $scopes = [], int $lifetime = 10 self::$clientSecret = $createdApiAccess->getSecret(); } + + protected static function addLanguageByLocale(string $locale): int + { + $client = static::createClient(); + $isoCode = substr($locale, 0, strpos($locale, '-')); + + // Copy resource assets into tmp folder to mimic an upload file path + $flagImage = __DIR__ . '/../../Resources/assets/lang/' . $isoCode . '.jpg'; + if (!file_exists($flagImage)) { + $flagImage = __DIR__ . '/../../Resources/assets/lang/en.jpg'; + } + + $tmpFlagImage = sys_get_temp_dir() . '/' . $isoCode . '.jpg'; + $tmpNoPictureImage = sys_get_temp_dir() . '/' . $isoCode . '-no-picture.jpg'; + copy($flagImage, $tmpFlagImage); + copy($flagImage, $tmpNoPictureImage); + + $command = new AddLanguageCommand( + $locale, + $isoCode, + $locale, + 'd/m/Y', + 'd/m/Y H:i:s', + $tmpFlagImage, + $tmpNoPictureImage, + false, + true, + [1] + ); + + $container = $client->getContainer(); + $commandBus = $container->get('prestashop.core.command_bus'); + + return $commandBus->handle($command)->getValue(); + } + + protected static function addShopGroup(string $groupName, string $color = null): int + { + $shopGroup = new \ShopGroup(); + $shopGroup->name = $groupName; + $shopGroup->active = true; + + if ($color !== null) { + $shopGroup->color = $color; + } + + if (!$shopGroup->add()) { + throw new \RuntimeException('Could not create shop group'); + } + + return (int) $shopGroup->id; + } + + protected static function addShop(string $shopName, int $shopGroupId, string $color = null): int + { + $shop = new \Shop(); + $shop->active = true; + $shop->id_shop_group = $shopGroupId; + // 2 : ID Category for "Home" in database + $shop->id_category = 2; + $shop->theme_name = _THEME_NAME_; + $shop->name = $shopName; + if ($color !== null) { + $shop->color = $color; + } + + if (!$shop->add()) { + throw new \RuntimeException('Could not create shop'); + } + $shop->setTheme(); + \Shop::resetContext(); + \Shop::resetStaticCache(); + + return (int) $shop->id; + } + + protected static function updateConfiguration(string $configurationKey, $value, ShopConstraint $shopConstraint = null): void + { + self::getContainer()->get(ShopConfigurationInterface::class)->set($configurationKey, $value, $shopConstraint ?: ShopConstraint::allShops()); + } } diff --git a/tests/Integration/ApiPlatform/CustomerGroupApiTest.php b/tests/Integration/ApiPlatform/CustomerGroupApiTest.php new file mode 100644 index 0000000..0be11f7 --- /dev/null +++ b/tests/Integration/ApiPlatform/CustomerGroupApiTest.php @@ -0,0 +1,239 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Group; +use Tests\Resources\DatabaseDump; + +class CustomerGroupApiTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + DatabaseDump::restoreTables(['group', 'group_lang', 'group_reduction', 'group_shop', 'category_group']); + self::createApiAccess(['customer_group_write', 'customer_group_read']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + DatabaseDump::restoreTables(['group', 'group_lang', 'group_reduction', 'group_shop', 'category_group']); + } + + /** + * @dataProvider getProtectedEndpoints + * + * @param string $method + * @param string $uri + */ + public function testProtectedEndpoints(string $method, string $uri): void + { + $client = static::createClient(); + $response = $client->request($method, $uri); + self::assertResponseStatusCodeSame(401); + + $content = $response->getContent(false); + $this->assertNotEmpty($content); + $decodedContent = json_decode($content, true); + $this->assertArrayHasKey('title', $decodedContent); + $this->assertArrayHasKey('detail', $decodedContent); + $this->assertStringContainsString('An error occurred', $decodedContent['title']); + $this->assertStringContainsString('Full authentication is required to access this resource.', $decodedContent['detail']); + } + + public function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/api/customers/group/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/api/customers/group', + ]; + + yield 'update endpoint' => [ + 'PUT', + '/api/customers/group/1', + ]; + } + + public function testAddCustomerGroup(): int + { + $numberOfGroups = count(\Group::getGroups(\Context::getContext()->language->id)); + + $bearerToken = $this->getBearerToken(['customer_group_write']); + $client = static::createClient(); + $response = $client->request('POST', '/api/customers/group', [ + 'auth_bearer' => $bearerToken, + 'json' => [ + 'localizedNames' => [ + 1 => 'test1', + ], + 'reductionPercent' => 10.3, + 'displayPriceTaxExcluded' => true, + 'showPrice' => true, + 'shopIds' => [1], + ], + ]); + self::assertResponseStatusCodeSame(201); + self::assertCount($numberOfGroups + 1, \Group::getGroups(\Context::getContext()->language->id)); + + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + $this->assertArrayHasKey('customerGroupId', $decodedResponse); + $customerGroupId = $decodedResponse['customerGroupId']; + $this->assertEquals( + [ + 'customerGroupId' => $customerGroupId, + 'localizedNames' => [ + 1 => 'test1', + ], + 'reductionPercent' => 10.3, + 'displayPriceTaxExcluded' => true, + 'showPrice' => true, + 'shopIds' => [1], + ], + $decodedResponse + ); + + return $customerGroupId; + } + + /** + * @depends testAddCustomerGroup + * + * @param int $customerGroupId + * + * @return int + */ + public function testUpdateCustomerGroup(int $customerGroupId): int + { + $numberOfGroups = count(\Group::getGroups(\Context::getContext()->language->id)); + + $bearerToken = $this->getBearerToken(['customer_group_write']); + $client = static::createClient(); + // Update customer group with partial data + $response = $client->request('PUT', '/api/customers/group/' . $customerGroupId, [ + 'auth_bearer' => $bearerToken, + 'json' => [ + 'localizedNames' => [ + 1 => 'new_test1', + ], + 'displayPriceTaxExcluded' => false, + 'shopIds' => [1], + ], + ]); + self::assertResponseStatusCodeSame(200); + // No new group + self::assertCount($numberOfGroups, \Group::getGroups(\Context::getContext()->language->id)); + + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + // Returned data has modified fields, the others haven't changed + $this->assertEquals( + [ + 'customerGroupId' => $customerGroupId, + 'localizedNames' => [ + 1 => 'new_test1', + ], + 'reductionPercent' => 10.3, + 'displayPriceTaxExcluded' => false, + 'showPrice' => true, + 'shopIds' => [1], + ], + $decodedResponse + ); + + return $customerGroupId; + } + + /** + * @depends testUpdateCustomerGroup + * + * @param int $customerGroupId + * + * @return int + */ + public function testGetCustomerGroup(int $customerGroupId): int + { + $bearerToken = $this->getBearerToken(['customer_group_read']); + $client = static::createClient(); + $response = $client->request('GET', '/api/customers/group/' . $customerGroupId, [ + 'auth_bearer' => $bearerToken, + ]); + self::assertResponseStatusCodeSame(200); + + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + // Returned data has modified fields, the others haven't changed + $this->assertEquals( + [ + 'customerGroupId' => $customerGroupId, + 'localizedNames' => [ + 1 => 'new_test1', + ], + 'reductionPercent' => 10.3, + 'displayPriceTaxExcluded' => false, + 'showPrice' => true, + 'shopIds' => [1], + ], + $decodedResponse + ); + + return $customerGroupId; + } + + /** + * @depends testGetCustomerGroup + * + * @param int $customerGroupId + * + * @return void + */ + public function testDeleteCustomerGroup(int $customerGroupId): void + { + $bearerToken = $this->getBearerToken(['customer_group_read', 'customer_group_write']); + $client = static::createClient(); + // Update customer group with partial data + $response = $client->request('DELETE', '/api/customers/group/' . $customerGroupId, [ + 'auth_bearer' => $bearerToken, + ]); + self::assertResponseStatusCodeSame(204); + $this->assertEmpty($response->getContent()); + + $client = static::createClient(); + $client->request('GET', '/api/customers/group/' . $customerGroupId, [ + 'auth_bearer' => $bearerToken, + ]); + self::assertResponseStatusCodeSame(404); + } +} diff --git a/tests/Integration/ApiPlatform/DomainSerializerTest.php b/tests/Integration/ApiPlatform/DomainSerializerTest.php new file mode 100644 index 0000000..b8cf464 --- /dev/null +++ b/tests/Integration/ApiPlatform/DomainSerializerTest.php @@ -0,0 +1,335 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use PrestaShop\Decimal\DecimalNumber; +use PrestaShop\PrestaShop\Core\Domain\ApiAccess\ValueObject\CreatedApiAccess; +use PrestaShop\PrestaShop\Core\Domain\CartRule\Command\EditCartRuleCommand; +use PrestaShop\PrestaShop\Core\Domain\CartRule\ValueObject\CartRuleAction; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Command\AddCustomerGroupCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\Query\GetCustomerGroupForEditing; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\QueryResult\EditableCustomerGroup; +use PrestaShop\PrestaShop\Core\Domain\Customer\Group\ValueObject\GroupId; +use PrestaShop\PrestaShop\Core\Domain\Product\Command\AddProductCommand; +use PrestaShop\PrestaShop\Core\Domain\Product\Query\GetProductForEditing; +use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductId; +use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductType; +use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; +use PrestaShopBundle\ApiPlatform\DomainSerializer; +use PrestaShopBundle\ApiPlatform\Resources\CustomerGroup; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +class DomainSerializerTest extends KernelTestCase +{ + /** + * @dataProvider getExpectedDenormalizedData + */ + public function testDenormalize($dataToDenormalize, $denormalizedObject, ?array $normalizationMapping = []): void + { + $serializer = self::getContainer()->get(DomainSerializer::class); + self::assertEquals($denormalizedObject, $serializer->denormalize($dataToDenormalize, get_class($denormalizedObject), null, [DomainSerializer::NORMALIZATION_MAPPING => $normalizationMapping])); + } + + public function getExpectedDenormalizedData() + { + yield [ + [ + 'localizedNames' => [ + 1 => 'test1', + 2 => 'test2', + ], + 'reductionPercent' => 10.3, + 'displayPriceTaxExcluded' => true, + 'showPrice' => true, + 'shopIds' => [1], + ], + new AddCustomerGroupCommand( + [ + 1 => 'test1', + 2 => 'test2', + ], + new DecimalNumber('10.3'), + true, + true, + [1] + ), + ]; + + $editCartRuleCommand = new EditCartRuleCommand(1); + $editCartRuleCommand->setDescription('test description'); + $editCartRuleCommand->setCode('test code'); + $editCartRuleCommand->setMinimumAmount('10', 1, true, true); + $editCartRuleCommand->setCustomerId(1); + $editCartRuleCommand->setLocalizedNames([1 => 'test1', 2 => 'test2']); + $editCartRuleCommand->setHighlightInCart(true); + $editCartRuleCommand->setAllowPartialUse(true); + $editCartRuleCommand->setPriority(1); + $editCartRuleCommand->setActive(true); + $editCartRuleCommand->setValidityDateRange(new \DateTimeImmutable('2023-08-23'), new \DateTimeImmutable('2023-08-25')); + $editCartRuleCommand->setTotalQuantity(100); + $editCartRuleCommand->setQuantityPerUser(1); + $editCartRuleCommand->setCartRuleAction(new CartRuleAction(true)); + yield [ + [ + 'cartRuleId' => 1, + 'description' => 'test description', + 'code' => 'test code', + 'minimumAmount' => ['minimumAmount' => '10', 'currencyId' => 1, 'taxIncluded' => true, 'shippingIncluded' => true], + 'customerId' => 1, + 'localizedNames' => [ + 1 => 'test1', + 2 => 'test2', + ], + 'highlightInCart' => true, + 'allowPartialUse' => true, + 'priority' => 1, + 'active' => true, + 'validityDateRange' => ['validFrom' => '2023-08-23', 'validTo' => '2023-08-25'], + 'totalQuantity' => 100, + 'quantityPerUser' => 1, + 'cartRuleAction' => ['freeShipping' => true], + // TODO: handle cartRuleAction with complex discount handle by business rules + // 'cartRuleAction' => ['freeShipping' => true, 'giftProduct' => ['productId': 1], 'discount' => ['amountDiscount' => ['amount' => 10]]]... + ], + $editCartRuleCommand, + ]; + + yield 'null value returns an empty object' => [ + null, + new CustomerGroup(), + ]; + + $customerGroupQuery = new GetCustomerGroupForEditing(51); + yield 'value object with wrong parameter converted via mapping' => [ + [ + 'groupId' => 51, + ], + $customerGroupQuery, + [ + '[groupId]' => '[customerGroupId]', + ], + ]; + + $customerGroupQuery = new GetCustomerGroupForEditing(51); + yield 'value object with proper parameter, extra mapping for normalization should ignore absent data and not override it with null' => [ + [ + 'customerGroupId' => 51, + ], + $customerGroupQuery, + [ + '[id]' => '[customerGroupId]', + '[reduction]' => '[reductionPercent]', + ], + ]; + + $customerGroupQuery = new GetCustomerGroupForEditing(51); + yield 'value object with wrong parameter plus extra mapping for normalization' => [ + [ + 'groupId' => 51, + ], + $customerGroupQuery, + [ + '[groupId]' => '[customerGroupId]', + '[id]' => '[customerGroupId]', + '[reduction]' => '[reductionPercent]', + ], + ]; + + yield 'single shop constraint' => [ + [ + 'shopId' => 42, + ], + ShopConstraint::shop(42), + ]; + + yield 'shop group constraint' => [ + [ + 'shopGroupId' => 42, + ], + ShopConstraint::shopGroup(42), + ]; + + yield 'all shop constraint' => [ + [], + ShopConstraint::allShops(), + ]; + + yield 'strict shop constraint' => [ + [ + 'shopGroupId' => null, + 'shopId' => 51, + 'isStrict' => true, + ], + ShopConstraint::shop(51, true), + ]; + + yield 'add product command' => [ + [ + 'productType' => ProductType::TYPE_STANDARD, + 'shopId' => 51, + ], + new AddProductCommand(ProductType::TYPE_STANDARD, 51), + ]; + + yield 'get product query' => [ + [ + 'productId' => 42, + 'shopConstraint' => [ + 'shopId' => 2, + ], + 'displayLanguageId' => 51, + ], + new GetProductForEditing(42, ShopConstraint::shop(2), 51), + ]; + } + + /** + * @dataProvider getNormalizationData + */ + public function testNormalize($dataToNormalize, $expectedNormalizedData, ?array $normalizationMapping = []): void + { + $serializer = self::getContainer()->get(DomainSerializer::class); + self::assertEquals($expectedNormalizedData, $serializer->normalize($dataToNormalize, null, [DomainSerializer::NORMALIZATION_MAPPING => $normalizationMapping])); + } + + public function getNormalizationData(): iterable + { + $createdApiAccess = new CreatedApiAccess(42, 'my_secret'); + yield 'normalize command result that contains a ValueObject' => [ + $createdApiAccess, + [ + 'apiAccessId' => 42, + 'secret' => 'my_secret', + ], + ]; + + $groupId = new GroupId(42); + yield 'normalize GroupId value object' => [ + $groupId, + [ + 'groupId' => 42, + ], + ]; + + $productId = new ProductId(42); + yield 'normalize ProductId value object' => [ + $productId, + [ + 'productId' => 42, + ], + ]; + + $editableCustomerGroup = new EditableCustomerGroup( + 42, + [ + 1 => 'Group', + 2 => 'Groupe', + ], + new DecimalNumber('10.67'), + false, + true, + [ + 1, + ], + ); + yield 'normalize object with displayPriceTaxExcluded that is a getter not starting by get' => [ + $editableCustomerGroup, + [ + 'id' => 42, + 'localizedNames' => [ + 1 => 'Group', + 2 => 'Groupe', + ], + 'reduction' => 10.67, + 'displayPriceTaxExcluded' => false, + 'showPrice' => true, + 'shopIds' => [ + 1, + ], + ], + ]; + + yield 'normalize object with displayPriceTaxExcluded that is a getter not starting by get and with extra mapping' => [ + $editableCustomerGroup, + [ + 'id' => 42, + 'localizedNames' => [ + 1 => 'Group', + 2 => 'Groupe', + ], + 'reduction' => 10.67, + 'reductionPercent' => 10.67, + 'displayPriceTaxExcluded' => false, + 'showPrice' => true, + 'shopIds' => [ + 1, + ], + ], + [ + '[reduction]' => '[reductionPercent]', + ], + ]; + + yield 'normalize single shop constraint' => [ + ShopConstraint::shop(42), + [ + 'shopId' => 42, + 'shopGroupId' => null, + 'isStrict' => false, + ], + ]; + + yield 'normalize group shop constraint' => [ + ShopConstraint::shopGroup(42), + [ + 'shopId' => null, + 'shopGroupId' => 42, + 'isStrict' => false, + ], + ]; + + yield 'normalize all shop constraint' => [ + ShopConstraint::allShops(), + [ + 'shopId' => null, + 'shopGroupId' => null, + 'isStrict' => false, + ], + ]; + + yield 'normalize all shop constraint strict' => [ + ShopConstraint::allShops(true), + [ + 'shopId' => null, + 'shopGroupId' => null, + 'isStrict' => true, + ], + ]; + } +} diff --git a/tests/Integration/ApiPlatform/ProductEndpointTest.php b/tests/Integration/ApiPlatform/ProductEndpointTest.php new file mode 100644 index 0000000..7399131 --- /dev/null +++ b/tests/Integration/ApiPlatform/ProductEndpointTest.php @@ -0,0 +1,302 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductType; +use PrestaShop\PrestaShop\Core\Domain\Shop\ValueObject\ShopConstraint; +use PrestaShop\PrestaShop\Core\Grid\Definition\Factory\ProductGridDefinitionFactory; +use PrestaShop\PrestaShop\Core\Grid\Query\ProductQueryBuilder; +use PrestaShop\PrestaShop\Core\Search\Filters\ProductFilters; +use Tests\Resources\Resetter\LanguageResetter; +use Tests\Resources\Resetter\ProductResetter; +use Tests\Resources\ResourceResetter; + +class ProductEndpointTest extends ApiTestCase +{ + protected const EN_LANG_ID = 1; + protected static int $frenchLangId; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + (new ResourceResetter())->backupTestModules(); + ProductResetter::resetProducts(); + LanguageResetter::resetLanguages(); + self::$frenchLangId = self::addLanguageByLocale('fr-FR'); + self::createApiAccess(['product_write', 'product_read']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + ProductResetter::resetProducts(); + LanguageResetter::resetLanguages(); + // Reset modules folder that are removed with the FR language + (new ResourceResetter())->resetTestModules(); + } + + /** + * @dataProvider getProtectedEndpoints + * + * @param string $method + * @param string $uri + */ + public function testProtectedEndpoints(string $method, string $uri): void + { + // Check that endpoints are not accessible without a proper Bearer token + $client = static::createClient(); + $response = $client->request($method, $uri); + self::assertResponseStatusCodeSame(401); + + $content = $response->getContent(false); + $this->assertNotEmpty($content); + $decodedContent = json_decode($content, true); + $this->assertArrayHasKey('title', $decodedContent); + $this->assertArrayHasKey('detail', $decodedContent); + $this->assertStringContainsString('An error occurred', $decodedContent['title']); + $this->assertStringContainsString('Full authentication is required to access this resource.', $decodedContent['detail']); + } + + public function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/api/product/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/api/product', + ]; + + yield 'update endpoint' => [ + 'PATCH', + '/api/product/1', + ]; + } + + public function testAddProduct(): int + { + $productsNumber = $this->getProductsNumber(); + $bearerToken = $this->getBearerToken(['product_write']); + $client = static::createClient(); + $response = $client->request('POST', '/api/product', [ + 'auth_bearer' => $bearerToken, + 'json' => [ + 'type' => ProductType::TYPE_STANDARD, + 'names' => [ + self::EN_LANG_ID => 'product name', + self::$frenchLangId => 'nom produit', + ], + ], + ]); + self::assertResponseStatusCodeSame(201); + $newProductsNumber = $this->getProductsNumber(); + self::assertEquals($productsNumber + 1, $newProductsNumber); + + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + $this->assertArrayHasKey('productId', $decodedResponse); + $productId = $decodedResponse['productId']; + $this->assertEquals( + [ + 'type' => ProductType::TYPE_STANDARD, + 'productId' => $productId, + 'names' => [ + self::EN_LANG_ID => 'product name', + self::$frenchLangId => 'nom produit', + ], + 'descriptions' => [ + self::EN_LANG_ID => '', + self::$frenchLangId => '', + ], + 'active' => false, + ], + $decodedResponse + ); + + return $productId; + } + + /** + * @depends testAddProduct + * + * @param int $productId + * + * @return int + */ + public function testPartialUpdateProduct(int $productId): int + { + $productsNumber = $this->getProductsNumber(); + $bearerToken = $this->getBearerToken(['product_write']); + $client = static::createClient(); + + // Update product with partial data, even multilang fields can be updated language by language + $response = $client->request('PATCH', '/api/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'json' => [ + 'names' => [ + self::$frenchLangId => 'nouveau nom', + ], + 'descriptions' => [ + self::EN_LANG_ID => 'new description', + ], + 'active' => true, + ], + ]); + self::assertResponseStatusCodeSame(200); + // No new product + $this->assertEquals($productsNumber, $this->getProductsNumber()); + + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + // Returned data has modified fields, the others haven't changed + $this->assertEquals( + [ + 'type' => ProductType::TYPE_STANDARD, + 'productId' => $productId, + 'names' => [ + self::EN_LANG_ID => 'product name', + self::$frenchLangId => 'nouveau nom', + ], + 'descriptions' => [ + self::EN_LANG_ID => 'new description', + self::$frenchLangId => '', + ], + 'active' => true, + ], + $decodedResponse + ); + + // Update product with partial data, only name default language the other names are not impacted + $response = $client->request('PATCH', '/api/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'json' => [ + 'names' => [ + self::EN_LANG_ID => 'new product name', + ], + ], + ]); + self::assertResponseStatusCodeSame(200); + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + // Returned data has modified fields, the others haven't changed + $this->assertEquals( + [ + 'type' => ProductType::TYPE_STANDARD, + 'productId' => $productId, + 'names' => [ + self::EN_LANG_ID => 'new product name', + self::$frenchLangId => 'nouveau nom', + ], + 'descriptions' => [ + self::EN_LANG_ID => 'new description', + self::$frenchLangId => '', + ], + 'active' => true, + ], + $decodedResponse + ); + + return $productId; + } + + /** + * @depends testPartialUpdateProduct + * + * @param int $productId + */ + public function testGetProduct(int $productId): int + { + $bearerToken = $this->getBearerToken(['product_read']); + $client = static::createClient(); + $response = $client->request('GET', '/api/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + ]); + self::assertResponseStatusCodeSame(200); + + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + // Returned data has modified fields, the others haven't changed + $this->assertEquals( + [ + 'type' => ProductType::TYPE_STANDARD, + 'productId' => $productId, + 'names' => [ + self::EN_LANG_ID => 'new product name', + self::$frenchLangId => 'nouveau nom', + ], + 'descriptions' => [ + self::EN_LANG_ID => 'new description', + self::$frenchLangId => '', + ], + 'active' => true, + ], + $decodedResponse + ); + + return $productId; + } + + /** + * @depends testGetProduct + * + * @param int $productId + */ + public function testDeleteProduct(int $productId): void + { + $productsNumber = $this->getProductsNumber(); + $bearerToken = $this->getBearerToken(['product_read', 'product_write']); + $client = static::createClient(); + // Update customer group with partial data + $response = $client->request('DELETE', '/api/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + ]); + self::assertResponseStatusCodeSame(204); + $this->assertEmpty($response->getContent()); + + // One less products + $this->assertEquals($productsNumber - 1, $this->getProductsNumber()); + + $client = static::createClient(); + $client->request('GET', '/api/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + ]); + self::assertResponseStatusCodeSame(404); + } + + protected function getProductsNumber(): int + { + /** @var ProductQueryBuilder $productQueryBuilder */ + $productQueryBuilder = $this->getContainer()->get('prestashop.core.grid.query_builder.product'); + $queryBuilder = $productQueryBuilder->getCountQueryBuilder(new ProductFilters(ShopConstraint::allShops(), ProductFilters::getDefaults(), ProductGridDefinitionFactory::GRID_ID)); + + return (int) $queryBuilder->executeQuery()->fetchOne(); + } +} diff --git a/tests/Integration/ApiPlatform/ProductMultiShopEndpointTest.php b/tests/Integration/ApiPlatform/ProductMultiShopEndpointTest.php new file mode 100644 index 0000000..41501b6 --- /dev/null +++ b/tests/Integration/ApiPlatform/ProductMultiShopEndpointTest.php @@ -0,0 +1,217 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0) + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use PrestaShop\PrestaShop\Core\Domain\Product\ValueObject\ProductType; +use PrestaShop\PrestaShop\Core\Multistore\MultistoreConfig; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Contracts\HttpClient\ResponseInterface; +use Tests\Resources\Resetter\ConfigurationResetter; +use Tests\Resources\Resetter\LanguageResetter; +use Tests\Resources\Resetter\ProductResetter; +use Tests\Resources\Resetter\ShopResetter; +use Tests\Resources\ResourceResetter; + +class ProductMultiShopEndpointTest extends ApiTestCase +{ + protected const EN_LANG_ID = 1; + protected static int $frenchLangId; + + protected const DEFAULT_SHOP_GROUP_ID = 1; + protected static int $secondShopGroupId; + + protected const DEFAULT_SHOP_ID = 1; + protected static int $secondShopId; + protected static int $thirdShopId; + protected static int $fourthShopId; + + protected static array $defaultProductData; + + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + (new ResourceResetter())->backupTestModules(); + ProductResetter::resetProducts(); + LanguageResetter::resetLanguages(); + ShopResetter::resetShops(); + ConfigurationResetter::resetConfiguration(); + + self::$frenchLangId = self::addLanguageByLocale('fr-FR'); + + self::updateConfiguration(MultistoreConfig::FEATURE_STATUS, 1); + self::$secondShopGroupId = self::addShopGroup('Second group'); + self::$secondShopId = self::addShop('Second shop', self::DEFAULT_SHOP_GROUP_ID); + self::$thirdShopId = self::addShop('Third shop', self::$secondShopGroupId); + self::$fourthShopId = self::addShop('Fourth shop', self::$secondShopGroupId); + self::createApiAccess(['product_write', 'product_read']); + + self::$defaultProductData = [ + 'type' => ProductType::TYPE_STANDARD, + 'names' => [ + self::EN_LANG_ID => 'product name', + self::$frenchLangId => 'nom produit', + ], + 'descriptions' => [ + self::EN_LANG_ID => '', + self::$frenchLangId => '', + ], + 'active' => false, + ]; + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + ProductResetter::resetProducts(); + LanguageResetter::resetLanguages(); + ShopResetter::resetShops(); + ConfigurationResetter::resetConfiguration(); + // Reset modules folder that are removed with the FR language + (new ResourceResetter())->resetTestModules(); + } + + public function testShopContextIsRequired(): void + { + $bearerToken = $this->getBearerToken(['product_write']); + $client = static::createClient(); + $response = $client->request('POST', '/api/product', [ + 'auth_bearer' => $bearerToken, + 'json' => [ + 'type' => ProductType::TYPE_STANDARD, + 'names' => [ + self::EN_LANG_ID => 'product name', + self::$frenchLangId => 'nom produit', + ], + ], + ]); + self::assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST); + $content = $response->getContent(false); + $this->assertStringContainsString('Multi shop is enabled, you must specify a shop context', $content); + } + + public function testCreateProductForFirstShop(): int + { + $bearerToken = $this->getBearerToken(['product_write']); + $client = static::createClient(); + $response = $client->request('POST', '/api/product', [ + 'auth_bearer' => $bearerToken, + 'json' => [ + 'type' => ProductType::TYPE_STANDARD, + 'names' => [ + self::EN_LANG_ID => 'product name', + self::$frenchLangId => 'nom produit', + ], + ], + 'extra' => [ + 'parameters' => [ + 'shopId' => self::DEFAULT_SHOP_ID, + ], + ], + ]); + self::assertResponseStatusCodeSame(201); + + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + $this->assertArrayHasKey('productId', $decodedResponse); + $productId = $decodedResponse['productId']; + $this->assertProductData($productId, self::$defaultProductData, $response); + + return $productId; + } + + /** + * @depends testCreateProductForFirstShop + * + * @param int $productId + * + * @return int + */ + public function testGetProductForFirstShopIsSuccessful(int $productId): int + { + $bearerToken = $this->getBearerToken(['product_read']); + $client = static::createClient(); + $response = $client->request('GET', '/api/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'extra' => [ + 'parameters' => [ + 'shopId' => self::DEFAULT_SHOP_ID, + ], + ], + ]); + self::assertResponseStatusCodeSame(200); + $this->assertProductData($productId, self::$defaultProductData, $response); + + return $productId; + } + + /** + * @depends testGetProductForFirstShopIsSuccessful + * + * @param int $productId + * + * @return int + */ + public function testGetProductForSecondShopIsFailing(int $productId): int + { + $bearerToken = $this->getBearerToken(['product_read']); + $client = static::createClient(); + $response = $client->request('GET', '/api/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'extra' => [ + 'parameters' => [ + 'shopId' => self::$secondShopId, + ], + ], + ]); + + self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); + $content = $response->getContent(false); + $this->assertStringContainsString(sprintf( + 'Could not find association between Product %d and Shop %d', + $productId, + self::$secondShopId + ), $content); + + return $productId; + } + + protected function assertProductData(int $productId, array $expectedData, ResponseInterface $response): void + { + // Merge expected data with default one, this way no need to always specify all the fields + $checkedData = array_merge(self::$defaultProductData, ['productId' => $productId], $expectedData); + $decodedResponse = json_decode($response->getContent(), true); + $this->assertNotFalse($decodedResponse); + $this->assertNotFalse($decodedResponse); + $this->assertArrayHasKey('productId', $decodedResponse); + $this->assertEquals( + $decodedResponse, + $checkedData + ); + } +} diff --git a/tests/Resources/assets/lang/en.jpg b/tests/Resources/assets/lang/en.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b924ed7a8b3719a1daee296d48c3b446eddf07d6 GIT binary patch literal 6249 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 O?*E{1%hdnTnEnrvNGgf| literal 0 HcmV?d00001 diff --git a/tests/Resources/assets/lang/fr.jpg b/tests/Resources/assets/lang/fr.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0b80c64c93e7bf73e0e5cd9f39bf919d595a222d GIT binary patch literal 7862 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 literal 0 HcmV?d00001