From be7bdec0d2d5ecfd95f87ac291b3a826799ee9b7 Mon Sep 17 00:00:00 2001 From: Jonathan LELIEVRE Date: Mon, 30 Dec 2024 19:12:26 +0100 Subject: [PATCH 1/2] Add shop association endpoint, and improve multishop tests to check updates with different shop parameters including list of shop IDs --- src/ApiPlatform/Resources/Product/Product.php | 3 + .../Resources/Product/ProductShops.php | 58 +++++++ .../ApiPlatform/ProductEndpointTest.php | 12 ++ .../ProductMultiShopEndpointTest.php | 146 ++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 src/ApiPlatform/Resources/Product/ProductShops.php diff --git a/src/ApiPlatform/Resources/Product/Product.php b/src/ApiPlatform/Resources/Product/Product.php index 7907756..bf063ed 100644 --- a/src/ApiPlatform/Resources/Product/Product.php +++ b/src/ApiPlatform/Resources/Product/Product.php @@ -102,6 +102,9 @@ class Product #[LocalizedValue] public array $descriptions; + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer']])] + public array $shopIds; + public const QUERY_MAPPING = [ '[_context][shopConstraint]' => '[shopConstraint]', '[_context][langId]' => '[displayLanguageId]', diff --git a/src/ApiPlatform/Resources/Product/ProductShops.php b/src/ApiPlatform/Resources/Product/ProductShops.php new file mode 100644 index 0000000..105d708 --- /dev/null +++ b/src/ApiPlatform/Resources/Product/ProductShops.php @@ -0,0 +1,58 @@ + + * @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\Exception\ProductNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Product\Query\GetProductForEditing; +use PrestaShop\PrestaShop\Core\Domain\Product\Shop\Command\SetProductShopsCommand; +use PrestaShop\PrestaShop\Core\Domain\Shop\Exception\ShopAssociationNotFound; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSPartialUpdate( + uriTemplate: '/product/{productId}/shops', + CQRSCommand: SetProductShopsCommand::class, + CQRSQuery: GetProductForEditing::class, + scopes: [ + 'product_write', + ], + CQRSQueryMapping: Product::QUERY_MAPPING, + CQRSCommandMapping: [ + '[associatedShopIds]' => '[shopIds]', + ], + ), + ], + exceptionToStatus: [ + ProductNotFoundException::class => Response::HTTP_NOT_FOUND, + ShopAssociationNotFound::class => Response::HTTP_NOT_FOUND, + ], +)] +class ProductShops extends Product +{ + public int $sourceShopId; + + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer']])] + public array $associatedShopIds; +} diff --git a/tests/Integration/ApiPlatform/ProductEndpointTest.php b/tests/Integration/ApiPlatform/ProductEndpointTest.php index 523936e..3ccc329 100644 --- a/tests/Integration/ApiPlatform/ProductEndpointTest.php +++ b/tests/Integration/ApiPlatform/ProductEndpointTest.php @@ -133,6 +133,9 @@ public function testAddProduct(): int 'fr-FR' => '', ], 'active' => false, + 'shopIds' => [ + 1, + ], ], $decodedResponse ); @@ -188,6 +191,9 @@ public function testPartialUpdateProduct(int $productId): int 'fr-FR' => '', ], 'active' => true, + 'shopIds' => [ + 1, + ], ], $decodedResponse ); @@ -221,6 +227,9 @@ public function testPartialUpdateProduct(int $productId): int 'fr-FR' => '', ], 'active' => true, + 'shopIds' => [ + 1, + ], ], $decodedResponse ); @@ -257,6 +266,9 @@ public function testGetProduct(int $productId): int 'fr-FR' => '', ], 'active' => true, + 'shopIds' => [ + 1, + ], ], $decodedResponse ); diff --git a/tests/Integration/ApiPlatform/ProductMultiShopEndpointTest.php b/tests/Integration/ApiPlatform/ProductMultiShopEndpointTest.php index bc95ac7..309c0d8 100644 --- a/tests/Integration/ApiPlatform/ProductMultiShopEndpointTest.php +++ b/tests/Integration/ApiPlatform/ProductMultiShopEndpointTest.php @@ -77,6 +77,9 @@ public static function setUpBeforeClass(): void 'fr-FR' => '', ], 'active' => false, + 'shopIds' => [ + self::DEFAULT_SHOP_ID, + ], ]; $featureFlagManager = self::getContainer()->get('PrestaShop\PrestaShop\Core\FeatureFlag\FeatureFlagManager'); @@ -204,6 +207,149 @@ public function testGetProductForSecondShopIsFailing(int $productId): int return $productId; } + /** + * @depends testGetProductForSecondShopIsFailing + * + * @param int $productId + * + * @return int + */ + public function testAssociateProductToShops(int $productId): int + { + $allShopIds = [ + self::DEFAULT_SHOP_ID, + self::$secondShopId, + self::$thirdShopId, + self::$fourthShopId, + ]; + $bearerToken = $this->getBearerToken(['product_write']); + $response = static::createClient()->request('PATCH', '/product/' . $productId . '/shops', [ + 'auth_bearer' => $bearerToken, + 'extra' => [ + 'parameters' => [ + 'shopId' => self::DEFAULT_SHOP_ID, + ], + ], + 'json' => [ + 'sourceShopId' => self::DEFAULT_SHOP_ID, + 'associatedShopIds' => $allShopIds, + ], + ]); + $updatedProduct = json_decode($response->getContent(), true); + $this->assertEquals($productId, $updatedProduct['productId']); + $this->assertEquals($allShopIds, $updatedProduct['shopIds']); + + return $productId; + } + + /** + * @depends testAssociateProductToShops + * + * @param int $productId + * + * @return int + */ + public function testUpdateProductForShops(int $productId): int + { + $bearerToken = $this->getBearerToken(['product_write']); + // Modify name for all shops + static::createClient()->request('PATCH', '/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'extra' => [ + 'parameters' => [ + 'allShops' => true, + ], + ], + 'json' => [ + 'names' => [ + 'en-US' => 'global product name', + ], + ], + ]); + self::assertResponseStatusCodeSame(200); + + // Check that all shops have been modified + foreach ([self::DEFAULT_SHOP_ID, self::$secondShopId, self::$thirdShopId, self::$fourthShopId] as $shopId) { + $product = $this->getProduct($productId, $shopId); + $this->assertEquals('global product name', $product['names']['en-US']); + } + + // Modify names for second group shop + static::createClient()->request('PATCH', '/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'extra' => [ + 'parameters' => [ + 'shopGroupId' => self::$secondShopGroupId, + ], + ], + 'json' => [ + 'names' => [ + 'en-US' => 'second group product name', + ], + ], + ]); + self::assertResponseStatusCodeSame(200); + + // Modify names for first shop + static::createClient()->request('PATCH', '/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'extra' => [ + 'parameters' => [ + 'shopId' => self::DEFAULT_SHOP_ID, + ], + ], + 'json' => [ + 'names' => [ + 'en-US' => 'first shop product name', + ], + ], + ]); + self::assertResponseStatusCodeSame(200); + + // Modify names for shop2 and shop4 + static::createClient()->request('PATCH', '/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'extra' => [ + 'parameters' => [ + 'shopIds' => [self::$secondShopId, self::$fourthShopId], + ], + ], + 'json' => [ + 'names' => [ + 'en-US' => 'even shops product name', + ], + ], + ]); + self::assertResponseStatusCodeSame(200); + + // Now check each shop modified content + $product = $this->getProduct($productId, self::DEFAULT_SHOP_ID); + $this->assertEquals('first shop product name', $product['names']['en-US']); + $product = $this->getProduct($productId, self::$secondShopId); + $this->assertEquals('even shops product name', $product['names']['en-US']); + $product = $this->getProduct($productId, self::$thirdShopId); + $this->assertEquals('second group product name', $product['names']['en-US']); + $product = $this->getProduct($productId, self::$fourthShopId); + $this->assertEquals('even shops product name', $product['names']['en-US']); + + return $productId; + } + + protected function getProduct(int $productId, int $shopId): array + { + $bearerToken = $this->getBearerToken(['product_read']); + $response = static::createClient()->request('GET', '/product/' . $productId, [ + 'auth_bearer' => $bearerToken, + 'extra' => [ + 'parameters' => [ + 'shopId' => $shopId, + ], + ], + ]); + + return json_decode($response->getContent(), true); + } + 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 From 75e96f1747a3284234027b12ac09ec87119f6800 Mon Sep 17 00:00:00 2001 From: Jonathan LELIEVRE Date: Tue, 31 Dec 2024 18:28:49 +0100 Subject: [PATCH 2/2] Add validation constraint on ApiClient and add related tests to check validation error messages --- .../Resources/ApiClient/ApiClient.php | 20 ++++++- .../ApiPlatform/ApiClientEndpointTest.php | 55 +++++++++++++++++++ tests/Integration/ApiPlatform/ApiTestCase.php | 37 +++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/ApiPlatform/Resources/ApiClient/ApiClient.php b/src/ApiPlatform/Resources/ApiClient/ApiClient.php index 572022d..2fadf80 100644 --- a/src/ApiPlatform/Resources/ApiClient/ApiClient.php +++ b/src/ApiPlatform/Resources/ApiClient/ApiClient.php @@ -24,15 +24,19 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\ApiClient\ApiClientSettings; use PrestaShop\PrestaShop\Core\Domain\ApiClient\Command\AddApiClientCommand; use PrestaShop\PrestaShop\Core\Domain\ApiClient\Command\DeleteApiClientCommand; use PrestaShop\PrestaShop\Core\Domain\ApiClient\Command\EditApiClientCommand; +use PrestaShop\PrestaShop\Core\Domain\ApiClient\Exception\ApiClientConstraintException; use PrestaShop\PrestaShop\Core\Domain\ApiClient\Exception\ApiClientNotFoundException; use PrestaShop\PrestaShop\Core\Domain\ApiClient\Query\GetApiClientForEditing; 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; +use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ @@ -51,8 +55,9 @@ ), new CQRSCreate( uriTemplate: '/api-client', + validationContext: ['groups' => ['Default', 'Create']], CQRSCommand: AddApiClientCommand::class, - scopes: ['api_client_write'] + scopes: ['api_client_write'], ), new CQRSPartialUpdate( uriTemplate: '/api-client/{apiClientId}', @@ -63,23 +68,34 @@ ), ], normalizationContext: ['skip_null_values' => false], - exceptionToStatus: [ApiClientNotFoundException::class => 404], + exceptionToStatus: [ + ApiClientNotFoundException::class => Response::HTTP_NOT_FOUND, + ApiClientConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], )] class ApiClient { #[ApiProperty(identifier: true)] public int $apiClientId; + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: ApiClientSettings::MAX_CLIENT_ID_LENGTH)] public string $clientId; + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Length(min: 1, max: ApiClientSettings::MAX_CLIENT_NAME_LENGTH)] public string $clientName; + #[Assert\Length(max: ApiClientSettings::MAX_DESCRIPTION_LENGTH)] public string $description; public ?string $externalIssuer; + #[Assert\NotBlank(groups: ['Create'])] public bool $enabled; + #[Assert\NotBlank(groups: ['Create'])] + #[Assert\Positive] public int $lifetime; public array $scopes; diff --git a/tests/Integration/ApiPlatform/ApiClientEndpointTest.php b/tests/Integration/ApiPlatform/ApiClientEndpointTest.php index c714ad4..dc8f28f 100644 --- a/tests/Integration/ApiPlatform/ApiClientEndpointTest.php +++ b/tests/Integration/ApiPlatform/ApiClientEndpointTest.php @@ -22,6 +22,10 @@ namespace PsApiResourcesTest\Integration\ApiPlatform; +use PrestaShop\PrestaShop\Core\Domain\ApiClient\ApiClientSettings; +use PrestaShop\PrestaShop\Core\Util\String\RandomString; +use Symfony\Component\HttpFoundation\Response; + class ApiClientEndpointTest extends ApiTestCase { public static function setUpBeforeClass(): void @@ -322,4 +326,55 @@ public function testDeleteApiClient(int $apiClientId): void ]); self::assertResponseStatusCodeSame(404); } + + public function testCreateInvalidApiClient(): void + { + $bearerToken = $this->getBearerToken(['api_client_write']); + $response = static::createClient()->request('POST', '/api-client', [ + 'auth_bearer' => $bearerToken, + 'json' => [ + 'clientId' => '', + 'clientName' => '', + 'description' => RandomString::generate(ApiClientSettings::MAX_DESCRIPTION_LENGTH + 1), + 'lifetime' => 0, + ], + ]); + self::assertResponseStatusCodeSame(Response::HTTP_UNPROCESSABLE_ENTITY); + // Get content without throwing exception since an error occurred, but we expected it + $decodedResponse = json_decode($response->getContent(false), true); + $this->assertNotFalse($decodedResponse); + $this->assertIsArray($decodedResponse); + + $expectedErrors = [ + [ + 'propertyPath' => 'clientId', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'clientId', + 'message' => 'This value is too short. It should have 1 character or more.', + ], + [ + 'propertyPath' => 'clientName', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'clientName', + 'message' => 'This value is too short. It should have 1 character or more.', + ], + [ + 'propertyPath' => 'lifetime', + 'message' => 'This value should be positive.', + ], + [ + 'propertyPath' => 'enabled', + 'message' => 'This value should not be blank.', + ], + [ + 'propertyPath' => 'description', + 'message' => sprintf('This value is too long. It should have %d characters or less.', ApiClientSettings::MAX_DESCRIPTION_LENGTH), + ], + ]; + $this->assertValidationErrors($decodedResponse, $expectedErrors); + } } diff --git a/tests/Integration/ApiPlatform/ApiTestCase.php b/tests/Integration/ApiPlatform/ApiTestCase.php index d3ed91c..48034a0 100644 --- a/tests/Integration/ApiPlatform/ApiTestCase.php +++ b/tests/Integration/ApiPlatform/ApiTestCase.php @@ -186,6 +186,43 @@ protected function prepareUploadedFile(string $assetFilePath): UploadedFile return new UploadedFile($tmpUploadedImagePath, basename($assetFilePath)); } + protected function assertValidationErrors(array $responseErrors, array $expectedErrors): void + { + foreach ($responseErrors as $errorDetail) { + $this->assertArrayHasKey('propertyPath', $errorDetail); + $this->assertArrayHasKey('message', $errorDetail); + $this->assertArrayHasKey('code', $errorDetail); + + $errorFound = false; + foreach ($expectedErrors as $expectedError) { + if ( + (empty($expectedError['message']) || $expectedError['message'] === $errorDetail['message']) + && (empty($expectedError['propertyPath']) || $expectedError['propertyPath'] === $errorDetail['propertyPath']) + ) { + $errorFound = true; + break; + } + } + + $this->assertTrue($errorFound, 'Found error that was not expected: ' . var_export($errorDetail, true)); + } + + foreach ($expectedErrors as $expectedError) { + $errorFound = false; + foreach ($responseErrors as $errorDetail) { + if ( + (empty($expectedError['message']) || $expectedError['message'] === $errorDetail['message']) + && (empty($expectedError['propertyPath']) || $expectedError['propertyPath'] === $errorDetail['propertyPath']) + ) { + $errorFound = true; + break; + } + } + + $this->assertTrue($errorFound, 'Could not find expected error: ' . var_export($expectedError, true)); + } + } + protected static function createApiClient(array $scopes = [], int $lifetime = 10000): void { $command = new AddApiClientCommand(