diff --git a/src/ApiPlatform/Resources/Customer/CustomerCart.php b/src/ApiPlatform/Resources/Customer/CustomerCart.php new file mode 100644 index 000000000..6656eebd9 --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/CustomerCart.php @@ -0,0 +1,67 @@ + + * @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 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; +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, 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. + * + * 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'], + ), + ], + exceptionToStatus: [ + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class CustomerCart +{ + public int $customerId; + + public int $cartId; + + public string $creationDate; + + public string $totalPrice; +} diff --git a/src/ApiPlatform/Resources/Customer/CustomerOrder.php b/src/ApiPlatform/Resources/Customer/CustomerOrder.php new file mode 100644 index 000000000..fda922587 --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/CustomerOrder.php @@ -0,0 +1,72 @@ + + * @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 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; +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, 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'], + ), + ], + exceptionToStatus: [ + CustomerNotFoundException::class => Response::HTTP_NOT_FOUND, + CustomerConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class CustomerOrder +{ + public int $customerId; + + public int $orderId; + + public string $orderPlacedDate; + + public string $paymentMethodName; + + public string $orderStatus; + + 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 new file mode 100644 index 000000000..e1de2800a --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/CustomerPrivateNote.php @@ -0,0 +1,61 @@ + + * @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 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; +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 +{ + 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] + #[CleanHtml] + public ?string $privateNote; +} diff --git a/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php b/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php new file mode 100644 index 000000000..de6bed9bf --- /dev/null +++ b/src/ApiPlatform/Resources/Customer/TransformGuestToCustomer.php @@ -0,0 +1,54 @@ + + * @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 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; +use Symfony\Component\HttpFoundation\Response; + +#[ApiResource( + operations: [ + new CQRSUpdate( + 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, + ], +)] +class TransformGuestToCustomer +{ + public int $customerId; +} diff --git a/tests/Integration/ApiPlatform/CustomerEndpointTest.php b/tests/Integration/ApiPlatform/CustomerEndpointTest.php index fe855e019..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 @@ -59,11 +59,26 @@ 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', ]; + yield 'set private note endpoint' => [ + 'PATCH', + '/customers/1/private-notes', + ]; + yield 'delete customer endpoint' => [ 'DELETE', '/customers/1', @@ -518,6 +533,114 @@ public function testGetCustomer(int $customerId): void $this->assertArrayHasKey('groupIds', $customer); } + /** + * @depends testAddCustomer + */ + public function testSetCustomerPrivateNote(int $customerId): void + { + $privateNote = 'A private note about this customer'; + + $return = $this->partialUpdateItem( + '/customers/' . $customerId . '/private-notes', + ['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']); + } + + /** + * @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); + } + + // 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); + } + + /** + * @depends testAddCustomer + */ + public function testGetCustomerOrders(int $customerId): void + { + $response = $this->getItem('/customers/' . $customerId . '/orders', ['customer_read']); + + // 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); + } + + /** + * @depends testAddCustomer + */ + 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']); + } + + 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); + } + /** * @depends testAddCustomer */ diff --git a/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php b/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php new file mode 100644 index 000000000..b5c949600 --- /dev/null +++ b/tests/Integration/ApiPlatform/CustomerTransformGuestEndpointTest.php @@ -0,0 +1,127 @@ + + * @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-customer']; + } + + 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-customer', + [], + ['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-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()