From 90bb37a65a3d48fc9be42a5d2fa6faf2e25adf84 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 --- .github/workflows/integration.yml | 1 + 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 ++++++++++++ 10 files changed, 1595 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 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 7e0cc7c..2350859 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -48,4 +48,5 @@ jobs: USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop_prestashop-git_1 composer create-test-db - name: Run integration tests run : | + USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop_prestashop-git_1 ls -la USER_ID=$(id -u) GROUP_ID=$(id -g) docker exec prestashop_prestashop-git_1 vendor/bin/phpunit -c modules/ps_apiresources/tests/Integration/phpunit.xml 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..c5f3607 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 ='/var/www/html/prestashop/tests/Integration/Resources/assets/lang/' . $isoCode . '.jpg'; + if (!file_exists($flagImage)) { + $flagImage = '/var/www/html/prestashop/tests/Integration/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 + ); + } +}