diff --git a/src/ApiPlatform/Resources/OrderState/BulkDeleteOrderStates.php b/src/ApiPlatform/Resources/OrderState/BulkDeleteOrderStates.php new file mode 100644 index 000000000..2a86cfd4b --- /dev/null +++ b/src/ApiPlatform/Resources/OrderState/BulkDeleteOrderStates.php @@ -0,0 +1,59 @@ + + * @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\OrderState; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Command\BulkDeleteOrderStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Exception\BulkDeleteOrderStateException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/order-states/bulk-delete', + CQRSCommand: BulkDeleteOrderStateCommand::class, + scopes: [ + 'order_state_write', + ], + allowEmptyBody: false, + ), + ], + exceptionToStatus: [ + // The handler catches every OrderStateException (including "not found") and rethrows a + // single BulkDeleteOrderStateException carrying the ids it could not delete, so a + // partial failure is unprocessable rather than a 404. + BulkDeleteOrderStateException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class BulkDeleteOrderStates +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $orderStateIds; +} diff --git a/src/ApiPlatform/Resources/OrderState/OrderState.php b/src/ApiPlatform/Resources/OrderState/OrderState.php new file mode 100644 index 000000000..1c28fbca2 --- /dev/null +++ b/src/ApiPlatform/Resources/OrderState/OrderState.php @@ -0,0 +1,146 @@ + + * @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\OrderState; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Command\AddOrderStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Command\DeleteOrderStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Command\EditOrderStateCommand; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Exception\DuplicateOrderStateNameException; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Exception\OrderStateConstraintException; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Exception\OrderStateNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\OrderState\Query\GetOrderStateForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSPartialUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/order-states/{orderStateId}', + requirements: ['orderStateId' => '\d+'], + CQRSQuery: GetOrderStateForEditing::class, + scopes: [ + 'order_state_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/order-states', + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddOrderStateCommand::class, + CQRSQuery: GetOrderStateForEditing::class, + scopes: [ + 'order_state_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::CREATE_COMMAND_MAPPING, + ), + new CQRSPartialUpdate( + uriTemplate: '/order-states/{orderStateId}', + requirements: ['orderStateId' => '\d+'], + read: false, + CQRSCommand: EditOrderStateCommand::class, + CQRSQuery: GetOrderStateForEditing::class, + scopes: [ + 'order_state_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::UPDATE_COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/order-states/{orderStateId}', + requirements: ['orderStateId' => '\d+'], + output: false, + CQRSCommand: DeleteOrderStateCommand::class, + scopes: [ + 'order_state_write', + ], + ), + ], + normalizationContext: ['skip_null_values' => false], + exceptionToStatus: [ + OrderStateNotFoundException::class => Response::HTTP_NOT_FOUND, + OrderStateConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + DuplicateOrderStateNameException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + ], +)] +class OrderState +{ + #[ApiProperty(identifier: true)] + public int $orderStateId; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)] + public array $names; + + #[LocalizedValue] + public array $templates; + + #[Assert\NotBlank(groups: ['Create'])] + public string $color; + + public bool $loggable; + + public bool $invoice; + + public bool $hidden; + + public bool $sendEmail; + + public bool $pdfInvoice; + + public bool $pdfDelivery; + + public bool $shipped; + + public bool $paid; + + public bool $delivery; + + /** + * Read only: order states are soft deleted, EditableOrderState exposes isDeleted(). + */ + public bool $deleted; + + public const QUERY_MAPPING = [ + '[localizedNames]' => '[names]', + '[localizedTemplates]' => '[templates]', + '[sendEmailEnabled]' => '[sendEmail]', + ]; + + public const CREATE_COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[templates]' => '[localizedTemplates]', + ]; + + public const UPDATE_COMMAND_MAPPING = [ + '[names]' => '[name]', + '[templates]' => '[template]', + ]; +} diff --git a/src/ApiPlatform/Resources/OrderState/OrderStateList.php b/src/ApiPlatform/Resources/OrderState/OrderStateList.php new file mode 100644 index 000000000..866c1cc70 --- /dev/null +++ b/src/ApiPlatform/Resources/OrderState/OrderStateList.php @@ -0,0 +1,56 @@ + + * @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\OrderState; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Search\Filters\OrderStatesFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/order-states', + scopes: [ + 'order_state_read', + ], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data_provider.order_states', + filtersClass: OrderStatesFilters::class, + filtersMapping: [ + '[orderStateId]' => '[id_order_state]', + ], + ), + ] +)] +class OrderStateList +{ + #[ApiProperty(identifier: true)] + public int $orderStateId; + + public string $name; + + public string $color; + + public const MAPPING = [ + '[id_order_state]' => '[orderStateId]', + ]; +} diff --git a/tests/Integration/ApiPlatform/OrderStateEndpointTest.php b/tests/Integration/ApiPlatform/OrderStateEndpointTest.php new file mode 100644 index 000000000..cd1c35ff6 --- /dev/null +++ b/tests/Integration/ApiPlatform/OrderStateEndpointTest.php @@ -0,0 +1,253 @@ + + * @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 OrderStateEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + self::resetTables(); + self::createApiClient(['order_state_read', 'order_state_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'order_state', + 'order_state_lang', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/order-states/1', + ]; + + yield 'create endpoint' => [ + 'POST', + '/order-states', + ]; + + yield 'patch endpoint' => [ + 'PATCH', + '/order-states/1', + ]; + + yield 'list endpoint' => [ + 'GET', + '/order-states', + ]; + } + + private function getCreateData(): array + { + return [ + 'names' => [ + 'en-US' => 'Awaiting review EN', + 'fr-FR' => 'Awaiting review FR', + ], + 'templates' => [ + 'en-US' => '', + 'fr-FR' => '', + ], + 'color' => '#4169E1', + 'loggable' => false, + 'invoice' => false, + 'hidden' => false, + 'sendEmail' => false, + 'pdfInvoice' => false, + 'pdfDelivery' => false, + 'shipped' => false, + 'paid' => false, + 'delivery' => false, + ]; + } + + /** + * The create operation replays GetOrderStateForEditing, so it returns the whole entity and + * not just its id. Asserting the complete structure here is what pins that contract. + */ + private function getExpectedOrderState(int $orderStateId, array $data): array + { + return array_merge($data, [ + 'orderStateId' => $orderStateId, + // Order states are soft deleted, a freshly created one is never flagged + 'deleted' => false, + ]); + } + + public function testAddOrderState(): int + { + $orderState = $this->createItem('/order-states', $this->getCreateData(), ['order_state_write']); + $this->assertArrayHasKey('orderStateId', $orderState); + $orderStateId = $orderState['orderStateId']; + + $this->assertEquals( + $this->getExpectedOrderState($orderStateId, $this->getCreateData()), + $orderState + ); + + return $orderStateId; + } + + /** + * @depends testAddOrderState + */ + public function testGetOrderState(int $orderStateId): int + { + $orderState = $this->getItem('/order-states/' . $orderStateId, ['order_state_read']); + + // The GET must return exactly what the POST returned + $this->assertEquals( + $this->getExpectedOrderState($orderStateId, $this->getCreateData()), + $orderState + ); + + return $orderStateId; + } + + /** + * @depends testGetOrderState + */ + public function testPartialUpdateOrderState(int $orderStateId): int + { + $patchData = [ + 'names' => [ + 'en-US' => 'Updated status EN', + 'fr-FR' => 'Updated status FR', + ], + 'color' => '#32CD32', + ]; + + $expected = $this->getExpectedOrderState( + $orderStateId, + array_merge($this->getCreateData(), $patchData) + ); + + // The partial update returns the updated entity through the same query as the GET + $updatedOrderState = $this->partialUpdateItem('/order-states/' . $orderStateId, $patchData, ['order_state_write']); + $this->assertEquals($expected, $updatedOrderState); + + // And a subsequent GET returns exactly the same thing + $this->assertEquals($expected, $this->getItem('/order-states/' . $orderStateId, ['order_state_read'])); + + return $orderStateId; + } + + /** + * @depends testPartialUpdateOrderState + */ + public function testListOrderStates(int $orderStateId): int + { + $paginatedOrderStates = $this->listItems('/order-states?orderBy=orderStateId&sortOrder=desc', ['order_state_read']); + $this->assertGreaterThanOrEqual(1, $paginatedOrderStates['totalItems']); + $this->assertEquals('orderStateId', $paginatedOrderStates['orderBy']); + + $firstOrderState = $paginatedOrderStates['items'][0]; + $this->assertEquals($orderStateId, $firstOrderState['orderStateId']); + + return $orderStateId; + } + + /** + * @depends testListOrderStates + */ + public function testDeleteOrderState(int $orderStateId): void + { + $return = $this->deleteItem('/order-states/' . $orderStateId, ['order_state_write']); + // This endpoint returns an empty response and a 204 HTTP code + $this->assertNull($return); + + // Order states are soft-deleted (existing orders may reference them): the record is + // still readable but flagged as deleted, and no longer appears in the listing. + $this->assertTrue($this->getItem('/order-states/' . $orderStateId, ['order_state_read'])['deleted']); + + $orderStates = $this->listItems('/order-states?orderBy=orderStateId&sortOrder=desc', ['order_state_read']); + $listedIds = array_column($orderStates['items'], 'orderStateId'); + $this->assertNotContains($orderStateId, $listedIds); + } + + /** + * @depends testDeleteOrderState + */ + public function testBulkDeleteOrderStates(): void + { + $bulkIds = []; + foreach (['A', 'B'] as $suffix) { + $data = $this->getCreateData(); + $data['names'] = [ + 'en-US' => 'Bulk status ' . $suffix, + 'fr-FR' => 'Bulk status ' . $suffix, + ]; + $created = $this->createItem('/order-states', $data, ['order_state_write']); + $bulkIds[] = $created['orderStateId']; + } + + $this->bulkDeleteItems('/order-states/bulk-delete', [ + 'orderStateIds' => $bulkIds, + ], ['order_state_write']); + + // Soft-deleted order states are still readable but flagged, and no longer listed + $orderStates = $this->listItems('/order-states?orderBy=orderStateId&sortOrder=desc', ['order_state_read']); + $listedIds = array_column($orderStates['items'], 'orderStateId'); + foreach ($bulkIds as $orderStateId) { + $this->assertTrue($this->getItem('/order-states/' . $orderStateId, ['order_state_read'])['deleted']); + $this->assertNotContains($orderStateId, $listedIds); + } + } + + public function testInvalidOrderState(): void + { + $invalidData = $this->getCreateData(); + $invalidData['names'] = [ + 'fr-FR' => 'Nom FR uniquement', + ]; + $invalidData['color'] = ''; + + $validationErrorsResponse = $this->createItem('/order-states', $invalidData, ['order_state_write'], Response::HTTP_UNPROCESSABLE_ENTITY); + $this->assertIsArray($validationErrorsResponse); + + $this->assertValidationErrors([ + [ + 'propertyPath' => 'names', + 'message' => 'The field names is required at least in your default language.', + ], + [ + 'propertyPath' => 'color', + 'message' => 'This value should not be blank.', + ], + ], $validationErrorsResponse); + } +}