From 0ef04aecca913461c0dc647ed846082e0d52f8ab Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 11 Jun 2026 07:55:56 +0200 Subject: [PATCH 01/10] Add Customer private-note endpoint Expose SetPrivateNoteAboutCustomerCommand through PATCH /customers/{customerId}/private-note, in a dedicated resource class so the rich Customer resource is left untouched. The body carries the privateNote string (mapped by matching field name). Adds an integration test and a scopes entry. Co-Authored-By: Claude Opus 4.8 --- .../Customer/CustomerPrivateNote.php | 57 +++++++++++++++++++ .../ApiPlatform/CustomerEndpointTest.php | 20 +++++++ 2 files changed, 77 insertions(+) create mode 100644 src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php diff --git a/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php b/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php new file mode 100644 index 000000000..933ec1c16 --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php @@ -0,0 +1,57 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\SetPrivateNoteAboutCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSPartialUpdate( + uriTemplate: '/customers/{customerId}/private-notes', + requirements: ['customerId' => '\d+'], + output: false, + read: false, + CQRSCommand: SetPrivateNoteAboutCustomerCommand::class, + scopes: ['customer_write'], + ), + ], + exceptionToStatus: [ + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class CustomerPrivateNote +{ + #[ApiProperty(identifier: true)] + public int $customerId; + + #[Assert\NotNull] + public string $privateNote; +} diff --git a/tests/Integration/ApiPlatform/CustomerEndpointTest.php b/tests/Integration/ApiPlatform/CustomerEndpointTest.php index fe855e019..69b1b8166 100644 --- a/tests/Integration/ApiPlatform/CustomerEndpointTest.php +++ b/tests/Integration/ApiPlatform/CustomerEndpointTest.php @@ -64,6 +64,11 @@ public static function getProtectedEndpoints(): iterable '/customers/1', ]; + yield 'set private note endpoint' => [ + 'PATCH', + '/customers/1/private-notes', + ]; + yield 'delete customer endpoint' => [ 'DELETE', '/customers/1', @@ -518,6 +523,21 @@ public function testGetCustomer(int $customerId): void $this->assertArrayHasKey('groupIds', $customer); } + /** + * @depends testAddCustomer + */ + public function testSetCustomerPrivateNote(int $customerId): void + { + $return = $this->partialUpdateItem( + '/customers/' . $customerId . '/private-notes', + ['privateNote' => 'A private note about this customer'], + ['customer_write'], + Response::HTTP_NO_CONTENT + ); + // This endpoint returns an empty response and a 204 HTTP code + $this->assertNull($return); + } + /** * @depends testAddCustomer */ From 03fc93a06e5e9e18b75e089c6d3f4e4786551637 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 11 Jun 2026 10:32:51 +0200 Subject: [PATCH 02/10] Add Customer orders and carts read endpoints Expose two customer read endpoints: - GET /customers/{customerId}/orders (GetCustomerOrders) -> order summaries - GET /customers/{customerId}/carts (GetCustomerCarts) -> cart summaries The query results (arrays of summary DTOs) are mapped through the resource via [_queryResult], like the ShowcaseCard endpoint. Adds integration tests and reuses the customer_read scope. Co-Authored-By: Claude Opus 4.8 --- .../Resources/Customer/CustomerCarts.php | 57 +++++++++++++++++++ .../Resources/Customer/CustomerOrders.php | 57 +++++++++++++++++++ .../ApiPlatform/CustomerEndpointTest.php | 36 ++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 src/ApiPlatform/Resources/Customer/CustomerCarts.php create mode 100644 src/ApiPlatform/Resources/Customer/CustomerOrders.php diff --git a/src/ApiPlatform/Resources/Customer/CustomerCarts.php b/src/ApiPlatform/Resources/Customer/CustomerCarts.php new file mode 100644 index 000000000..3c9440c5b --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/CustomerCarts.php @@ -0,0 +1,57 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerCarts; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/customers/{customerId}/carts', + requirements: ['customerId' => '\d+'], + CQRSQuery: GetCustomerCarts::class, + scopes: ['customer_read'], + CQRSQueryMapping: [ + '[_queryResult]' => '[carts]', + ], + ), + ], + exceptionToStatus: [ + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CustomerCarts +{ + #[ApiProperty(identifier: true)] + public int $customerId; + + /** + * @var array + */ + public array $carts; +} diff --git a/src/ApiPlatform/Resources/Customer/CustomerOrders.php b/src/ApiPlatform/Resources/Customer/CustomerOrders.php new file mode 100644 index 000000000..46a6066c4 --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/CustomerOrders.php @@ -0,0 +1,57 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerOrders; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/customers/{customerId}/orders', + requirements: ['customerId' => '\d+'], + CQRSQuery: GetCustomerOrders::class, + scopes: ['customer_read'], + CQRSQueryMapping: [ + '[_queryResult]' => '[orders]', + ], + ), + ], + exceptionToStatus: [ + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class CustomerOrders +{ + #[ApiProperty(identifier: true)] + public int $customerId; + + /** + * @var array + */ + public array $orders; +} diff --git a/tests/Integration/ApiPlatform/CustomerEndpointTest.php b/tests/Integration/ApiPlatform/CustomerEndpointTest.php index 69b1b8166..bac66596c 100644 --- a/tests/Integration/ApiPlatform/CustomerEndpointTest.php +++ b/tests/Integration/ApiPlatform/CustomerEndpointTest.php @@ -59,6 +59,16 @@ public static function getProtectedEndpoints(): iterable '/customers/1/details', ]; + yield 'get customer orders endpoint' => [ + 'GET', + '/customers/1/orders', + ]; + + yield 'get customer carts endpoint' => [ + 'GET', + '/customers/1/carts', + ]; + yield 'update customer endpoint' => [ 'PATCH', '/customers/1', @@ -538,6 +548,32 @@ public function testSetCustomerPrivateNote(int $customerId): void $this->assertNull($return); } + /** + * @depends testAddCustomer + */ + public function testGetCustomerOrders(int $customerId): void + { + $response = $this->getItem('/customers/' . $customerId . '/orders', ['customer_read']); + + // A freshly created customer has no orders yet + $this->assertSame($customerId, $response['customerId']); + $this->assertArrayHasKey('orders', $response); + $this->assertSame([], $response['orders']); + } + + /** + * @depends testAddCustomer + */ + public function testGetCustomerCarts(int $customerId): void + { + $response = $this->getItem('/customers/' . $customerId . '/carts', ['customer_read']); + + // A freshly created customer has no carts yet + $this->assertSame($customerId, $response['customerId']); + $this->assertArrayHasKey('carts', $response); + $this->assertSame([], $response['carts']); + } + /** * @depends testAddCustomer */ From 8fa495f1431085f1c1ea89b28b08d7364faec90c Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Tue, 16 Jun 2026 14:30:50 +0200 Subject: [PATCH 03/10] Add Customer transform-guest-to-customer endpoint PUT /customers/{customerId}/transform-to-customers (TransformGuestToCustomerCommand) turns a guest customer into a registered one. Added as a standalone resource class and a separate test to avoid colliding with the in-progress Customer PRs. Co-Authored-By: Claude Opus 4.8 --- .../Customer/TransformGuestToCustomer.php | 53 +++++++++ .../CustomerTransformGuestEndpointTest.php | 106 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php create mode 100644 tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php diff --git a/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php b/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php new file mode 100644 index 000000000..663957696 --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php @@ -0,0 +1,53 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Command\TransformGuestToCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerTransformationException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSUpdate( + uriTemplate: '/customers/{customerId}/transform-to-customers', + requirements: ['customerId' => '\d+'], + output: false, + allowEmptyBody: true, + CQRSCommand: TransformGuestToCustomerCommand::class, + scopes: ['customer_write'], + ), + ], + exceptionToStatus: [ + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + CustomerTransformationException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class TransformGuestToCustomer +{ + #[ApiProperty(identifier: true)] + public int $customerId; +} diff --git a/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php b/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php new file mode 100644 index 000000000..4f42eb9c9 --- /dev/null +++ b/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php @@ -0,0 +1,106 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; + +class CustomerTransformGuestEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + self::createApiClient(['customer_write', 'customer_read']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'customer', + 'customer_group', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'transform endpoint' => ['PUT', '/customers/1/transform-to-customers']; + } + + private function createGuest(): int + { + $guest = $this->createItem('/customers', [ + 'firstName' => 'Jane', + 'lastName' => 'GUEST', + 'email' => 'jane.transform@example.com', + 'password' => 'INVALID', + 'genderId' => 2, + 'guest' => true, + 'defaultGroupId' => 1, + 'groupIds' => [1], + 'enabled' => false, + ], ['customer_write']); + + $this->assertTrue($guest['guest']); + + return $guest['customerId']; + } + + public function testTransformGuestToCustomer(): int + { + $customerId = $this->createGuest(); + + $this->updateItem( + '/customers/' . $customerId . '/transform-to-customers', + [], + ['customer_write'], + Response::HTTP_NO_CONTENT + ); + + // The guest is now a registered customer + $customer = $this->getItem('/customers/' . $customerId, ['customer_read']); + $this->assertFalse($customer['guest']); + + return $customerId; + } + + /** + * @depends testTransformGuestToCustomer + */ + public function testTransformAlreadyRegisteredCustomerFails(int $customerId): void + { + // Transforming a customer that is no longer a guest is rejected + $this->updateItem( + '/customers/' . $customerId . '/transform-to-customers', + [], + ['customer_write'], + Response::HTTP_UNPROCESSABLE_ENTITY + ); + } +} From 41a300a452afd3069d98df0f826fae36d91f0e50 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 9 Jul 2026 09:46:49 +0200 Subject: [PATCH 04/10] Add customer address-creation info read endpoint Expose GetCustomerForAddressCreation as GET /customers/address-creation-info ?customerEmail=... (scope customer_read): return the minimal customer info the address creation flow needs (customerId, firstName, lastName, company). Experimental combination of CQRSGet + QueryParameter (previously only exercised on CQRSGetCollection). Related to PrestaShop/PrestaShop#39630 Co-Authored-By: Claude Opus 4.8 --- .../Customer/AddressCreationCustomerInfo.php | 63 +++++++++++++++++ ...ddressCreationCustomerInfoEndpointTest.php | 69 +++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php create mode 100644 tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php diff --git a/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php b/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php new file mode 100644 index 000000000..3c3e1309c --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php @@ -0,0 +1,63 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; + +use ApiPlatform\Metadata\Parameters; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerByEmailNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerForAddressCreation; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/customers/address-creation-info', + CQRSQuery: GetCustomerForAddressCreation::class, + scopes: [ + 'customer_read', + ], + parameters: new Parameters([ + new QueryParameter( + key: 'customerEmail', + required: true, + description: 'Customer email address' + ), + ]), + ), + ], + exceptionToStatus: [ + CustomerByEmailNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class AddressCreationCustomerInfo +{ + public int $customerId; + + public string $firstName; + + public string $lastName; + + public ?string $company; +} diff --git a/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php b/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php new file mode 100644 index 000000000..576c83fc2 --- /dev/null +++ b/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php @@ -0,0 +1,69 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Response; + +class AddressCreationCustomerInfoEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::createApiClient(['customer_read']); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get customer address-creation info endpoint' => ['GET', '/customers/address-creation-info?customerEmail=foo@example.com']; + } + + public function testGetCustomerForAddressCreation(): void + { + $row = \Db::getInstance()->getRow( + 'SELECT `id_customer`, `firstname`, `lastname`, `email` + FROM `' . _DB_PREFIX_ . 'customer` WHERE `active` = 1 ORDER BY `id_customer` ASC' + ); + + $result = $this->getItem( + '/customers/address-creation-info?customerEmail=' . urlencode((string) $row['email']), + ['customer_read'] + ); + + $this->assertArrayHasKey('customerId', $result); + $this->assertSame((int) $row['id_customer'], $result['customerId']); + $this->assertSame((string) $row['firstname'], $result['firstName']); + $this->assertSame((string) $row['lastname'], $result['lastName']); + $this->assertArrayHasKey('company', $result); + } + + public function testGetForUnknownEmailReturnsNotFound(): void + { + $this->requestApi( + 'GET', + '/customers/address-creation-info?customerEmail=' . urlencode('nobody-' . uniqid() . '@example.invalid'), + null, + ['customer_read'], + Response::HTTP_NOT_FOUND + ); + } +} From 5a2c8ef71be03b9a1005f1494e30612d1589c866 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 9 Jul 2026 10:05:01 +0200 Subject: [PATCH 05/10] Fix CS import order + Rector pluralization (info -> infos) Co-Authored-By: Claude Opus 4.8 --- .../Resources/Customer/AddressCreationCustomerInfo.php | 4 ++-- .../ApiPlatform/AddressCreationCustomerInfoEndpointTest.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php b/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php index 3c3e1309c..073a32ab8 100644 --- a/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php +++ b/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php @@ -22,9 +22,9 @@ namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; +use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Parameters; use ApiPlatform\Metadata\QueryParameter; -use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerByEmailNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerForAddressCreation; use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; @@ -33,7 +33,7 @@ #[ApiResource( operations: [ new CQRSGet( - uriTemplate: '/customers/address-creation-info', + uriTemplate: '/customers/address-creation-infos', CQRSQuery: GetCustomerForAddressCreation::class, scopes: [ 'customer_read', diff --git a/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php b/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php index 576c83fc2..447ea53d5 100644 --- a/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php +++ b/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php @@ -34,7 +34,7 @@ public static function setUpBeforeClass(): void public static function getProtectedEndpoints(): iterable { - yield 'get customer address-creation info endpoint' => ['GET', '/customers/address-creation-info?customerEmail=foo@example.com']; + yield 'get customer address-creation info endpoint' => ['GET', '/customers/address-creation-infos?customerEmail=foo@example.com']; } public function testGetCustomerForAddressCreation(): void @@ -45,7 +45,7 @@ public function testGetCustomerForAddressCreation(): void ); $result = $this->getItem( - '/customers/address-creation-info?customerEmail=' . urlencode((string) $row['email']), + '/customers/address-creation-infos?customerEmail=' . urlencode((string) $row['email']), ['customer_read'] ); @@ -60,7 +60,7 @@ public function testGetForUnknownEmailReturnsNotFound(): void { $this->requestApi( 'GET', - '/customers/address-creation-info?customerEmail=' . urlencode('nobody-' . uniqid() . '@example.invalid'), + '/customers/address-creation-infos?customerEmail=' . urlencode('nobody-' . uniqid() . '@example.invalid'), null, ['customer_read'], Response::HTTP_NOT_FOUND From 4c19fd3a9a53f93674784eb0001b1d51afef1c97 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 20 Aug 2026 11:22:08 +0200 Subject: [PATCH 06/10] Merge the Customer endpoints into one domain PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates #218, #225, #243 and #342. #218 and #225 both wrote CustomerEndpointTest.php and conflict on cherry-pick, which is the usual sign that they belonged in one PR. #342 is dropped rather than merged. It exposed GetCustomerForAddressCreation as GET /customers/address-creation-infos?customerEmail=, but that query is already listed in EXCLUDED_CQRS_CLASSES on dev with the USELESS_DUPLICATE reason — and the exclusion is right: GET /customers/search matches on email among other fields and returns idCustomer, firstname, lastname and company, a strict superset of the four fields the dropped endpoint returned. The exclusion entry already exists, so this PR adds nothing for it. The private-note test now asserts its work: the note is the privateNote of the generalInformation of GET /customers/{customerId}/details, so the write finally has a read side to check against. It previously asserted the 204 and nothing else. Co-Authored-By: Claude Opus 5 --- .../Customer/AddressCreationCustomerInfo.php | 63 ----------------- ...ddressCreationCustomerInfoEndpointTest.php | 69 ------------------- .../ApiPlatform/CustomerEndpointTest.php | 9 ++- 3 files changed, 8 insertions(+), 133 deletions(-) delete mode 100644 src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php delete mode 100644 tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php diff --git a/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php b/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php deleted file mode 100644 index 073a32ab8..000000000 --- a/src/ApiPlatform/Resources/Customer/AddressCreationCustomerInfo.php +++ /dev/null @@ -1,63 +0,0 @@ - - * @copyright Since 2007 PrestaShop SA and Contributors - * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 - */ - -declare(strict_types=1); - -namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; - -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Parameters; -use ApiPlatform\Metadata\QueryParameter; -use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerByEmailNotFoundException; -use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerForAddressCreation; -use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; -use Symfony\Component\HttpFoundation\Response; - -#[ApiResource( - operations: [ - new CQRSGet( - uriTemplate: '/customers/address-creation-infos', - CQRSQuery: GetCustomerForAddressCreation::class, - scopes: [ - 'customer_read', - ], - parameters: new Parameters([ - new QueryParameter( - key: 'customerEmail', - required: true, - description: 'Customer email address' - ), - ]), - ), - ], - exceptionToStatus: [ - CustomerByEmailNotFoundException::class => Response::HTTP_NOT_FOUND, - ], -)] -class AddressCreationCustomerInfo -{ - public int $customerId; - - public string $firstName; - - public string $lastName; - - public ?string $company; -} diff --git a/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php b/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php deleted file mode 100644 index 447ea53d5..000000000 --- a/tests/Integration/ApiPlatform/AddressCreationCustomerInfoEndpointTest.php +++ /dev/null @@ -1,69 +0,0 @@ - - * @copyright Since 2007 PrestaShop SA and Contributors - * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 - */ - -declare(strict_types=1); - -namespace PsApiResourcesTest\Integration\ApiPlatform; - -use Symfony\Component\HttpFoundation\Response; - -class AddressCreationCustomerInfoEndpointTest extends ApiTestCase -{ - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - self::createApiClient(['customer_read']); - } - - public static function getProtectedEndpoints(): iterable - { - yield 'get customer address-creation info endpoint' => ['GET', '/customers/address-creation-infos?customerEmail=foo@example.com']; - } - - public function testGetCustomerForAddressCreation(): void - { - $row = \Db::getInstance()->getRow( - 'SELECT `id_customer`, `firstname`, `lastname`, `email` - FROM `' . _DB_PREFIX_ . 'customer` WHERE `active` = 1 ORDER BY `id_customer` ASC' - ); - - $result = $this->getItem( - '/customers/address-creation-infos?customerEmail=' . urlencode((string) $row['email']), - ['customer_read'] - ); - - $this->assertArrayHasKey('customerId', $result); - $this->assertSame((int) $row['id_customer'], $result['customerId']); - $this->assertSame((string) $row['firstname'], $result['firstName']); - $this->assertSame((string) $row['lastname'], $result['lastName']); - $this->assertArrayHasKey('company', $result); - } - - public function testGetForUnknownEmailReturnsNotFound(): void - { - $this->requestApi( - 'GET', - '/customers/address-creation-infos?customerEmail=' . urlencode('nobody-' . uniqid() . '@example.invalid'), - null, - ['customer_read'], - Response::HTTP_NOT_FOUND - ); - } -} diff --git a/tests/Integration/ApiPlatform/CustomerEndpointTest.php b/tests/Integration/ApiPlatform/CustomerEndpointTest.php index bac66596c..0b80d6cbc 100644 --- a/tests/Integration/ApiPlatform/CustomerEndpointTest.php +++ b/tests/Integration/ApiPlatform/CustomerEndpointTest.php @@ -538,14 +538,21 @@ public function testGetCustomer(int $customerId): void */ public function testSetCustomerPrivateNote(int $customerId): void { + $privateNote = 'A private note about this customer'; + $return = $this->partialUpdateItem( '/customers/' . $customerId . '/private-notes', - ['privateNote' => 'A private note about this customer'], + ['privateNote' => $privateNote], ['customer_write'], Response::HTTP_NO_CONTENT ); // This endpoint returns an empty response and a 204 HTTP code $this->assertNull($return); + + // The private note is the generalInformation of the customer details: the write had no + // read side to check against while the two endpoints lived in separate PRs. + $details = $this->getItem('/customers/' . $customerId . '/details', ['customer_read']); + $this->assertSame($privateNote, $details['generalInformation']['privateNote']); } /** From a60a4647d54dbe47fd3f4333106771e099274f5f Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 20 Aug 2026 12:10:34 +0200 Subject: [PATCH 07/10] Fix customer orders and carts endpoints returning only the identifier GetCustomerOrders and GetCustomerCarts both return a list of row objects, not a scalar. QueryResultSerializerTrait only wraps a query result behind the "_queryResult" key when the result is a scalar, so the ['[_queryResult]' => '[orders]'] / '[carts]' mappings the source PRs relied on never fired and the responses came back with customerId alone. Turn both into CQRSGetCollection operations describing one row, which is the idiomatic shape for /customers/{customerId}/orders and /carts anyway, and rename the resource classes to the singular accordingly. --- .../{CustomerCarts.php => CustomerCart.php} | 26 ++++++++------- .../{CustomerOrders.php => CustomerOrder.php} | 32 ++++++++++++------- .../ApiPlatform/CustomerEndpointTest.php | 14 ++++---- 3 files changed, 42 insertions(+), 30 deletions(-) rename src/ApiPlatform/Resources/Customer/{CustomerCarts.php => CustomerCart.php} (73%) rename src/ApiPlatform/Resources/Customer/{CustomerOrders.php => CustomerOrder.php} (69%) diff --git a/src/ApiPlatform/Resources/Customer/CustomerCarts.php b/src/ApiPlatform/Resources/Customer/CustomerCart.php similarity index 73% rename from src/ApiPlatform/Resources/Customer/CustomerCarts.php rename to src/ApiPlatform/Resources/Customer/CustomerCart.php index 3c9440c5b..08bbfd640 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerCarts.php +++ b/src/ApiPlatform/Resources/Customer/CustomerCart.php @@ -26,32 +26,36 @@ use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerCarts; -use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; use Symfony\Component\HttpFoundation\Response; +/** + * One cart of a customer, as summarized by GetCustomerCarts. + * + * The query returns a list, so this is a collection operation: QueryResultSerializerTrait + * only wraps a query result behind "_queryResult" when it is a scalar, so the + * ['[_queryResult]' => '[...]'] mapping the source PR used could never fill anything and the + * response came back with the identifier alone. + */ #[ApiResource( operations: [ - new CQRSGet( + new CQRSGetCollection( uriTemplate: '/customers/{customerId}/carts', requirements: ['customerId' => '\d+'], CQRSQuery: GetCustomerCarts::class, scopes: ['customer_read'], - CQRSQueryMapping: [ - '[_queryResult]' => '[carts]', - ], ), ], exceptionToStatus: [ CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, ], )] -class CustomerCarts +class CustomerCart { #[ApiProperty(identifier: true)] - public int $customerId; + public int $cartId; + + public string $creationDate; - /** - * @var array - */ - public array $carts; + public string $totalPrice; } diff --git a/src/ApiPlatform/Resources/Customer/CustomerOrders.php b/src/ApiPlatform/Resources/Customer/CustomerOrder.php similarity index 69% rename from src/ApiPlatform/Resources/Customer/CustomerOrders.php rename to src/ApiPlatform/Resources/Customer/CustomerOrder.php index 46a6066c4..fb713fbc8 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerOrders.php +++ b/src/ApiPlatform/Resources/Customer/CustomerOrder.php @@ -26,32 +26,42 @@ use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerOrders; -use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; use Symfony\Component\HttpFoundation\Response; +/** + * One order of a customer, as summarized by GetCustomerOrders. + * + * The query returns a list, so this is a collection operation: QueryResultSerializerTrait + * only wraps a query result behind "_queryResult" when it is a scalar, so the + * ['[_queryResult]' => '[...]'] mapping the source PR used could never fill anything and the + * response came back with the identifier alone. + */ #[ApiResource( operations: [ - new CQRSGet( + new CQRSGetCollection( uriTemplate: '/customers/{customerId}/orders', requirements: ['customerId' => '\d+'], CQRSQuery: GetCustomerOrders::class, scopes: ['customer_read'], - CQRSQueryMapping: [ - '[_queryResult]' => '[orders]', - ], ), ], exceptionToStatus: [ CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, ], )] -class CustomerOrders +class CustomerOrder { #[ApiProperty(identifier: true)] - public int $customerId; + public int $orderId; + + public string $orderPlacedDate; + + public string $paymentMethodName; + + public string $orderStatus; + + public int $orderProductsCount; - /** - * @var array - */ - public array $orders; + public string $totalPaid; } diff --git a/tests/Integration/ApiPlatform/CustomerEndpointTest.php b/tests/Integration/ApiPlatform/CustomerEndpointTest.php index 0b80d6cbc..57024beb6 100644 --- a/tests/Integration/ApiPlatform/CustomerEndpointTest.php +++ b/tests/Integration/ApiPlatform/CustomerEndpointTest.php @@ -562,10 +562,9 @@ public function testGetCustomerOrders(int $customerId): void { $response = $this->getItem('/customers/' . $customerId . '/orders', ['customer_read']); - // A freshly created customer has no orders yet - $this->assertSame($customerId, $response['customerId']); - $this->assertArrayHasKey('orders', $response); - $this->assertSame([], $response['orders']); + // The query returns a list, so the endpoint is a collection: a freshly created customer + // has no orders yet, hence an empty list + $this->assertSame([], $response); } /** @@ -575,10 +574,9 @@ public function testGetCustomerCarts(int $customerId): void { $response = $this->getItem('/customers/' . $customerId . '/carts', ['customer_read']); - // A freshly created customer has no carts yet - $this->assertSame($customerId, $response['customerId']); - $this->assertArrayHasKey('carts', $response); - $this->assertSame([], $response['carts']); + // The query returns a list, so the endpoint is a collection: a freshly created customer + // has no carts yet, hence an empty list + $this->assertSame([], $response); } /** From ef549e1eaa76dcd6046aaed0879118223f7cf5a8 Mon Sep 17 00:00:00 2001 From: Jonathan Danse Date: Thu, 20 Aug 2026 14:23:04 +0200 Subject: [PATCH 08/10] Declare the customer collections exactly like ProductImageList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identifier and the requirements kept the two collection routes from being registered at all, so both endpoints answered 404. ProductImageList declares the same shape — a sub-collection under a parent id — with neither, and it works. --- src/ApiPlatform/Resources/Customer/CustomerCart.php | 13 ++++++------- .../Resources/Customer/CustomerOrder.php | 13 ++++++------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/ApiPlatform/Resources/Customer/CustomerCart.php b/src/ApiPlatform/Resources/Customer/CustomerCart.php index 08bbfd640..2bdc82413 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerCart.php +++ b/src/ApiPlatform/Resources/Customer/CustomerCart.php @@ -22,7 +22,6 @@ namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; -use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerCarts; @@ -32,16 +31,15 @@ /** * One cart of a customer, as summarized by GetCustomerCarts. * - * The query returns a list, so this is a collection operation: QueryResultSerializerTrait - * only wraps a query result behind "_queryResult" when it is a scalar, so the - * ['[_queryResult]' => '[...]'] mapping the source PR used could never fill anything and the - * response came back with the identifier alone. + * The query returns a list, so this is a collection operation, declared like ProductImageList: + * QueryResultSerializerTrait only wraps a query result behind "_queryResult" when it is a + * scalar, so the ['[_queryResult]' => '[carts]'] mapping the source PR used could never fill + * anything and the response came back with the identifier alone. */ #[ApiResource( operations: [ new CQRSGetCollection( uriTemplate: '/customers/{customerId}/carts', - requirements: ['customerId' => '\d+'], CQRSQuery: GetCustomerCarts::class, scopes: ['customer_read'], ), @@ -52,7 +50,8 @@ )] class CustomerCart { - #[ApiProperty(identifier: true)] + public int $customerId; + public int $cartId; public string $creationDate; diff --git a/src/ApiPlatform/Resources/Customer/CustomerOrder.php b/src/ApiPlatform/Resources/Customer/CustomerOrder.php index fb713fbc8..269e29d2a 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerOrder.php +++ b/src/ApiPlatform/Resources/Customer/CustomerOrder.php @@ -22,7 +22,6 @@ namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; -use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerOrders; @@ -32,16 +31,15 @@ /** * One order of a customer, as summarized by GetCustomerOrders. * - * The query returns a list, so this is a collection operation: QueryResultSerializerTrait - * only wraps a query result behind "_queryResult" when it is a scalar, so the - * ['[_queryResult]' => '[...]'] mapping the source PR used could never fill anything and the - * response came back with the identifier alone. + * The query returns a list, so this is a collection operation, declared like ProductImageList: + * QueryResultSerializerTrait only wraps a query result behind "_queryResult" when it is a + * scalar, so the ['[_queryResult]' => '[orders]'] mapping the source PR used could never fill + * anything and the response came back with the identifier alone. */ #[ApiResource( operations: [ new CQRSGetCollection( uriTemplate: '/customers/{customerId}/orders', - requirements: ['customerId' => '\d+'], CQRSQuery: GetCustomerOrders::class, scopes: ['customer_read'], ), @@ -52,7 +50,8 @@ )] class CustomerOrder { - #[ApiProperty(identifier: true)] + public int $customerId; + public int $orderId; public string $orderPlacedDate; From 9d23e65a64c9bee713ef92432971f990e866482f Mon Sep 17 00:00:00 2001 From: mattgoud Date: Wed, 23 Sep 2026 18:55:21 +0200 Subject: [PATCH 09/10] Address the review on the Customer endpoints - Drop the identifier attribute on CustomerPrivateNote and TransformGuestToCustomer, which made API Platform reserve /customer_private_notes/{id} and /transform_guest_to_customers/{id} as not-exposed routes - Type privateNote as ?string so an explicit null gets the same 422 as an omitted field instead of a 400 - Add the customerId requirement on the orders and carts collections - Document totalPaid (total_paid_real) and the carts filter - Cover the invalid private note payloads --- .../Resources/Customer/CustomerCart.php | 5 +++ .../Resources/Customer/CustomerOrder.php | 4 +++ .../Customer/CustomerPrivateNote.php | 8 +++-- .../Customer/TransformGuestToCustomer.php | 2 -- .../ApiPlatform/CustomerEndpointTest.php | 35 ++++++++++++++++++- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/ApiPlatform/Resources/Customer/CustomerCart.php b/src/ApiPlatform/Resources/Customer/CustomerCart.php index 2bdc82413..e3c0a9e9f 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerCart.php +++ b/src/ApiPlatform/Resources/Customer/CustomerCart.php @@ -35,11 +35,16 @@ * QueryResultSerializerTrait only wraps a query result behind "_queryResult" when it is a * scalar, so the ['[_queryResult]' => '[carts]'] mapping the source PR used could never fill * anything and the response came back with the identifier alone. + * + * Only the carts that were never turned into an order are listed: the core calls + * Cart::getCustomerCarts($customerId, false), so a customer whose carts all became orders + * gets an empty list. */ #[ApiResource( operations: [ new CQRSGetCollection( uriTemplate: '/customers/{customerId}/carts', + requirements: ['customerId' => '\d+'], CQRSQuery: GetCustomerCarts::class, scopes: ['customer_read'], ), diff --git a/src/ApiPlatform/Resources/Customer/CustomerOrder.php b/src/ApiPlatform/Resources/Customer/CustomerOrder.php index 269e29d2a..ded3ff76f 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerOrder.php +++ b/src/ApiPlatform/Resources/Customer/CustomerOrder.php @@ -40,6 +40,7 @@ operations: [ new CQRSGetCollection( uriTemplate: '/customers/{customerId}/orders', + requirements: ['customerId' => '\d+'], CQRSQuery: GetCustomerOrders::class, scopes: ['customer_read'], ), @@ -62,5 +63,8 @@ class CustomerOrder public int $orderProductsCount; + /** + * Amount actually received (total_paid_real), not the order total. + */ public string $totalPaid; } diff --git a/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php b/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php index 933ec1c16..8b431ece7 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php +++ b/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php @@ -22,7 +22,6 @@ namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; -use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\Customer\Command\SetPrivateNoteAboutCustomerCommand; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; @@ -49,9 +48,12 @@ )] class CustomerPrivateNote { - #[ApiProperty(identifier: true)] public int $customerId; + /** + * Nullable so that an explicit null reaches the NotNull constraint and gets the same 422 + * as an omitted field, instead of a 400 from the denormalizer. + */ #[Assert\NotNull] - public string $privateNote; + public ?string $privateNote; } diff --git a/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php b/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php index 663957696..bf2f68fbb 100644 --- a/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php +++ b/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php @@ -22,7 +22,6 @@ namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; -use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\Customer\Command\TransformGuestToCustomerCommand; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; @@ -48,6 +47,5 @@ )] class TransformGuestToCustomer { - #[ApiProperty(identifier: true)] public int $customerId; } diff --git a/tests/Integration/ApiPlatform/CustomerEndpointTest.php b/tests/Integration/ApiPlatform/CustomerEndpointTest.php index 57024beb6..0aef80ffd 100644 --- a/tests/Integration/ApiPlatform/CustomerEndpointTest.php +++ b/tests/Integration/ApiPlatform/CustomerEndpointTest.php @@ -555,6 +555,37 @@ public function testSetCustomerPrivateNote(int $customerId): void $this->assertSame($privateNote, $details['generalInformation']['privateNote']); } + /** + * @depends testAddCustomer + */ + public function testInvalidCustomerPrivateNote(int $customerId): void + { + $expectedErrors = [ + [ + 'propertyPath' => 'privateNote', + 'message' => 'This value should not be null.', + ], + ]; + + // Omitting the field and sending an explicit null must both end up in the same 422. The + // empty object is sent as a raw body: an empty $data array would send no body at all. + $invalidBodies = [ + 'omitted privateNote' => '{}', + 'null privateNote' => '{"privateNote":null}', + ]; + foreach ($invalidBodies as $invalidBody) { + $validationErrorsResponse = $this->partialUpdateItem( + '/customers/' . $customerId . '/private-notes', + null, + ['customer_write'], + Response::HTTP_UNPROCESSABLE_ENTITY, + ['body' => $invalidBody] + ); + $this->assertIsArray($validationErrorsResponse); + $this->assertValidationErrors($expectedErrors, $validationErrorsResponse); + } + } + /** * @depends testAddCustomer */ @@ -575,7 +606,9 @@ public function testGetCustomerCarts(int $customerId): void $response = $this->getItem('/customers/' . $customerId . '/carts', ['customer_read']); // The query returns a list, so the endpoint is a collection: a freshly created customer - // has no carts yet, hence an empty list + // has no carts yet, hence an empty list. Only the empty case can be covered here: the core + // excludes the carts already turned into an order, and there is no API endpoint to create + // a cart that stays unordered for a given customer. $this->assertSame([], $response); } From fd7bc01cea02fd33f2ee3d693e3435f860fdc61a Mon Sep 17 00:00:00 2001 From: mattgoud Date: Fri, 25 Sep 2026 09:48:01 +0200 Subject: [PATCH 10/10] Address the second review on the Customer endpoints - Map CustomerConstraintException to 422 on the orders, carts and transform endpoints: customer id 0 passes the route requirement and was answering 500 - Apply the CleanHtml constraint of the back-office note form - Singular transform-to-customer URI, skipped by the Rector rule - validationContext on the transform operation - Test the carts endpoint on a real cart, the 404 and the invalid id of the transform endpoint, and the invalid id of orders and carts - Rename tearDownBeforeClass, which PHPUnit never called, so the customer tables are really restored --- .../Resources/Customer/CustomerCart.php | 2 + .../Resources/Customer/CustomerOrder.php | 2 + .../Customer/CustomerPrivateNote.php | 2 + .../Customer/TransformGuestToCustomer.php | 5 +- .../ApiPlatform/CustomerEndpointTest.php | 47 +++++++++++++++---- .../CustomerTransformGuestEndpointTest.php | 27 +++++++++-- tests/Rector/ApiResourceUriTemplateRector.php | 1 + 7 files changed, 73 insertions(+), 13 deletions(-) diff --git a/src/ApiPlatform/Resources/Customer/CustomerCart.php b/src/ApiPlatform/Resources/Customer/CustomerCart.php index e3c0a9e9f..6656eebd9 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerCart.php +++ b/src/ApiPlatform/Resources/Customer/CustomerCart.php @@ -23,6 +23,7 @@ namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerCarts; use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; @@ -51,6 +52,7 @@ ], exceptionToStatus: [ CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, ], )] class CustomerCart diff --git a/src/ApiPlatform/Resources/Customer/CustomerOrder.php b/src/ApiPlatform/Resources/Customer/CustomerOrder.php index ded3ff76f..fda922587 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerOrder.php +++ b/src/ApiPlatform/Resources/Customer/CustomerOrder.php @@ -23,6 +23,7 @@ namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Customer\Query\GetCustomerOrders; use PrestaShopBundle\ApiPlatform\Metadata\CQRSGetCollection; @@ -47,6 +48,7 @@ ], exceptionToStatus: [ CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, ], )] class CustomerOrder diff --git a/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php b/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php index 8b431ece7..e1de2800a 100644 --- a/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php +++ b/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php @@ -23,6 +23,7 @@ namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Customer; use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\CleanHtml; use PrestaShop\PrestaShop\Core\Domain\Customer\Command\SetPrivateNoteAboutCustomerCommand; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; @@ -55,5 +56,6 @@ class CustomerPrivateNote * as an omitted field, instead of a 400 from the denormalizer. */ #[Assert\NotNull] + #[CleanHtml] public ?string $privateNote; } diff --git a/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php b/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php index bf2f68fbb..de6bed9bf 100644 --- a/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php +++ b/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php @@ -24,6 +24,7 @@ use ApiPlatform\Metadata\ApiResource; use PrestaShop\PrestaShop\Core\Domain\Customer\Command\TransformGuestToCustomerCommand; +use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerConstraintException; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerNotFoundException; use PrestaShop\PrestaShop\Core\Domain\Customer\Exception\CustomerTransformationException; use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; @@ -32,16 +33,18 @@ #[ApiResource( operations: [ new CQRSUpdate( - uriTemplate: '/customers/{customerId}/transform-to-customers', + uriTemplate: '/customers/{customerId}/transform-to-customer', requirements: ['customerId' => '\d+'], output: false, allowEmptyBody: true, CQRSCommand: TransformGuestToCustomerCommand::class, scopes: ['customer_write'], + validationContext: ['groups' => ['Default', 'Update']], ), ], exceptionToStatus: [ CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, CustomerTransformationException::class => Response::HTTP_UNPROCESSABLE_ENTITY, ], )] diff --git a/tests/Integration/ApiPlatform/CustomerEndpointTest.php b/tests/Integration/ApiPlatform/CustomerEndpointTest.php index 0aef80ffd..6f4e2f626 100644 --- a/tests/Integration/ApiPlatform/CustomerEndpointTest.php +++ b/tests/Integration/ApiPlatform/CustomerEndpointTest.php @@ -31,15 +31,15 @@ class CustomerEndpointTest extends ApiTestCase public static function setUpBeforeClass(): void { parent::setUpBeforeClass(); - DatabaseDump::restoreTables(['customer', 'customer_group']); + DatabaseDump::restoreTables(['cart', 'customer', 'customer_group']); self::createApiClient(['customer_write', 'customer_read']); } - public static function tearDownBeforeClass(): void + public static function tearDownAfterClass(): void { - parent::tearDownBeforeClass(); + parent::tearDownAfterClass(); // Reset DB as it was before this test - DatabaseDump::restoreTables(['customer', 'customer_group']); + DatabaseDump::restoreTables(['cart', 'customer', 'customer_group']); } public static function getProtectedEndpoints(): iterable @@ -584,6 +584,16 @@ public function testInvalidCustomerPrivateNote(int $customerId): void $this->assertIsArray($validationErrorsResponse); $this->assertValidationErrors($expectedErrors, $validationErrorsResponse); } + + // Same CleanHtml rule as the note form of the back office + $validationErrorsResponse = $this->partialUpdateItem( + '/customers/' . $customerId . '/private-notes', + ['privateNote' => ''], + ['customer_write'], + Response::HTTP_UNPROCESSABLE_ENTITY + ); + $this->assertIsArray($validationErrorsResponse); + $this->assertValidationErrors([['propertyPath' => 'privateNote']], $validationErrorsResponse); } /** @@ -603,13 +613,32 @@ public function testGetCustomerOrders(int $customerId): void */ public function testGetCustomerCarts(int $customerId): void { + // The query returns a list, so the endpoint is a collection: a freshly created customer + // has no carts yet, hence an empty list + $this->assertSame([], $this->getItem('/customers/' . $customerId . '/carts', ['customer_read'])); + + // There is no API endpoint to create a cart for a given customer, so it is created here. + // It never becomes an order, so the query lists it (carts turned into orders are excluded). + $cart = new \Cart(); + $cart->id_customer = $customerId; + $cart->id_currency = (int) \Configuration::get('PS_CURRENCY_DEFAULT'); + $cart->id_lang = (int) \Configuration::get('PS_LANG_DEFAULT'); + $cart->id_shop = (int) \Configuration::get('PS_SHOP_DEFAULT'); + $cart->save(); + $response = $this->getItem('/customers/' . $customerId . '/carts', ['customer_read']); + $this->assertCount(1, $response); + $this->assertSame($customerId, $response[0]['customerId']); + $this->assertSame((int) $cart->id, $response[0]['cartId']); + $this->assertIsString($response[0]['creationDate']); + $this->assertIsString($response[0]['totalPrice']); + } - // The query returns a list, so the endpoint is a collection: a freshly created customer - // has no carts yet, hence an empty list. Only the empty case can be covered here: the core - // excludes the carts already turned into an order, and there is no API endpoint to create - // a cart that stays unordered for a given customer. - $this->assertSame([], $response); + public function testGetCustomerOrdersAndCartsInvalidCustomerId(): void + { + // 0 matches the \d+ requirement but is rejected by the CustomerId value object + $this->getItem('/customers/0/orders', ['customer_read'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->getItem('/customers/0/carts', ['customer_read'], Response::HTTP_UNPROCESSABLE_ENTITY); } /** diff --git a/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php b/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php index 4f42eb9c9..b5c949600 100644 --- a/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php +++ b/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php @@ -50,7 +50,7 @@ protected static function resetTables(): void public static function getProtectedEndpoints(): iterable { - yield 'transform endpoint' => ['PUT', '/customers/1/transform-to-customers']; + yield 'transform endpoint' => ['PUT', '/customers/1/transform-to-customer']; } private function createGuest(): int @@ -77,7 +77,7 @@ public function testTransformGuestToCustomer(): int $customerId = $this->createGuest(); $this->updateItem( - '/customers/' . $customerId . '/transform-to-customers', + '/customers/' . $customerId . '/transform-to-customer', [], ['customer_write'], Response::HTTP_NO_CONTENT @@ -97,7 +97,28 @@ public function testTransformAlreadyRegisteredCustomerFails(int $customerId): vo { // Transforming a customer that is no longer a guest is rejected $this->updateItem( - '/customers/' . $customerId . '/transform-to-customers', + '/customers/' . $customerId . '/transform-to-customer', + [], + ['customer_write'], + Response::HTTP_UNPROCESSABLE_ENTITY + ); + } + + public function testTransformUnknownCustomerNotFound(): void + { + $this->updateItem( + '/customers/999999/transform-to-customer', + [], + ['customer_write'], + Response::HTTP_NOT_FOUND + ); + } + + public function testTransformInvalidCustomerId(): void + { + // 0 matches the \d+ requirement but is rejected by the CustomerId value object + $this->updateItem( + '/customers/0/transform-to-customer', [], ['customer_write'], Response::HTTP_UNPROCESSABLE_ENTITY diff --git a/tests/Rector/ApiResourceUriTemplateRector.php b/tests/Rector/ApiResourceUriTemplateRector.php index 7e300a3b0..575d1055f 100644 --- a/tests/Rector/ApiResourceUriTemplateRector.php +++ b/tests/Rector/ApiResourceUriTemplateRector.php @@ -122,6 +122,7 @@ final class ApiResourceUriTemplateRector extends AbstractRector 'close', 'send-password-reset-email', 'set-tax-rule-group', + 'transform-to-customer', ]; public function __construct()