diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5141105 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout module + uses: actions/checkout@v4 + + - name: Allow phpstan/extension-installer composer plugin + run: composer config --global allow-plugins.phpstan/extension-installer true + + # A module repository has no Magento to analyse against, so one is built around it. + - name: Set up Magento project with module + id: magento + uses: magebitcom/setup-magento-module-action@v1 + with: + magento-version: "2.4.7-p3" + php-version: "8.3" + composer-auth-user: ${{ secrets.MAGENTO_AUTH_USER }} + composer-auth-pass: ${{ secrets.MAGENTO_AUTH_PASS }} + extra-packages: >- + phpstan/phpstan:^2.0 + bitexpert/phpstan-magento:^0.42 + phpstan/extension-installer:^1.4 + phpunit/phpunit:^9.5 + opis/json-schema:^2.6 + + # bitexpert/phpstan-magento only invents a factory class when Composer cannot find a real + # one, and that fallback is broken on newer phpstan/phpstan. Compiling writes the real + # factories to disk, so the fallback never runs. + - name: Generate DI factories for PHPStan + working-directory: ${{ steps.magento.outputs.project-path }} + run: | + php bin/magento module:enable --all + php -d memory_limit=-1 bin/magento setup:di:compile + + # The factories compiled above are loaded by a Magento autoloader at runtime, not through + # Composer, so PHPStan has to be pointed at them. Left unfound, it invents its own and trips + # over the broken fallback described above. + - name: Run PHPStan + working-directory: ${{ steps.magento.outputs.project-path }} + run: | + cat > phpstan-ci.neon <<'NEON' + includes: + - ${{ steps.magento.outputs.module-path }}/phpstan.neon + parameters: + scanDirectories: + - generated/code + NEON + vendor/bin/phpstan analyse "${{ steps.magento.outputs.module-path }}" \ + -c phpstan-ci.neon --no-progress --memory-limit=4G + + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout module + uses: actions/checkout@v4 + + - name: Set up Magento project with module + id: magento + uses: magebitcom/setup-magento-module-action@v1 + with: + magento-version: "2.4.7-p3" + php-version: "8.3" + composer-auth-user: ${{ secrets.MAGENTO_AUTH_USER }} + composer-auth-pass: ${{ secrets.MAGENTO_AUTH_PASS }} + extra-packages: >- + phpunit/phpunit:^9.5 + opis/json-schema:^2.6 + + # Extension attribute interfaces such as CartExtensionInterface are generated, and tests that + # mock one need the real thing rather than the empty stub that ships in vendor. + - name: Generate the extension attribute classes + working-directory: ${{ steps.magento.outputs.project-path }} + run: | + php bin/magento module:enable --all + php -d memory_limit=-1 bin/magento setup:di:compile + + - name: Run unit tests + working-directory: ${{ steps.magento.outputs.project-path }} + run: >- + vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist + "${{ steps.magento.outputs.module-path }}/Test/Unit" --testdox diff --git a/Api/Data/CheckoutMetaInterface.php b/Api/Data/CheckoutMetaInterface.php index f42d86d..48f186e 100644 --- a/Api/Data/CheckoutMetaInterface.php +++ b/Api/Data/CheckoutMetaInterface.php @@ -22,6 +22,7 @@ interface CheckoutMetaInterface public const ORDER_ID = 'order_id'; public const WEBHOOK_URL = 'webhook_url'; public const SUBMITTED_FULFILLMENT = 'submitted_fulfillment'; + public const BUYER_CONSENT = 'buyer_consent'; public const CREATED_AT = 'created_at'; public const UPDATED_AT = 'updated_at'; @@ -105,6 +106,19 @@ public function getSubmittedFulfillment(): ?string; */ public function setSubmittedFulfillment(?string $submittedFulfillment): self; + /** + * The data-processing consent the agent recorded for this buyer, as JSON. + * + * @return string|null + */ + public function getBuyerConsent(): ?string; + + /** + * @param string|null $buyerConsent + * @return $this + */ + public function setBuyerConsent(?string $buyerConsent): self; + /** * Get created at * diff --git a/Api/Data/IdempotencyKeyInterface.php b/Api/Data/IdempotencyKeyInterface.php deleted file mode 100644 index 4dce4a2..0000000 --- a/Api/Data/IdempotencyKeyInterface.php +++ /dev/null @@ -1,125 +0,0 @@ - - * @license MIT - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Api\Data; - -/** - * Interface for Idempotency Key Model - * Provides methods to manage idempotency records for request deduplication - */ -interface IdempotencyKeyInterface -{ - public const ENTITY_ID = 'entity_id'; - public const KEY = 'key'; - public const REQUEST_HASH = 'request_hash'; - public const RESPONSE_STATUS = 'response_status'; - public const RESPONSE_BODY = 'response_body'; - public const CREATED_AT = 'created_at'; - public const UPDATED_AT = 'updated_at'; - - /** - * Get entity ID - * - * @return int|null - */ - public function getEntityId(): ?int; - - /** - * Get key - * - * @return string|null - */ - public function getKey(): ?string; - - /** - * Set key - * - * @param string $key - * @return $this - */ - public function setKey(string $key): self; - - /** - * Get request hash - * - * @return string|null - */ - public function getRequestHash(): ?string; - - /** - * Set request hash - * - * @param string $requestHash - * @return $this - */ - public function setRequestHash(string $requestHash): self; - - /** - * Get response status - * - * @return int|null - */ - public function getResponseStatus(): ?int; - - /** - * Set response status - * - * @param int $responseStatus - * @return $this - */ - public function setResponseStatus(int $responseStatus): self; - - /** - * Get response body - * - * @return string|null - */ - public function getResponseBody(): ?string; - - /** - * Set response body - * - * @param string $responseBody - * @return $this - */ - public function setResponseBody(string $responseBody): self; - - /** - * Get created at - * - * @return string|null - */ - public function getCreatedAt(): ?string; - - /** - * Set created at - * - * @param string $createdAt - * @return $this - */ - public function setCreatedAt(string $createdAt): self; - - /** - * Get updated at - * - * @return string|null - */ - public function getUpdatedAt(): ?string; - - /** - * Set updated at - * - * @param string $updatedAt - * @return $this - */ - public function setUpdatedAt(string $updatedAt): self; -} diff --git a/Api/Data/OrderAdjustmentInterface.php b/Api/Data/OrderAdjustmentInterface.php new file mode 100644 index 0000000..9acac1b --- /dev/null +++ b/Api/Data/OrderAdjustmentInterface.php @@ -0,0 +1,76 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Api\Data; + +/** + * One post-order adjustment an agent asked this store to record against an order. + */ +interface OrderAdjustmentInterface +{ + public const ENTITY_ID = 'entity_id'; + public const ORDER_ID = 'order_id'; + public const ADJUSTMENT_ID = 'adjustment_id'; + public const PAYLOAD = 'payload'; + public const CREATED_AT = 'created_at'; + public const UPDATED_AT = 'updated_at'; + + /** + * @return int|null + */ + public function getEntityId(): ?int; + + /** + * @return int|null + */ + public function getOrderId(): ?int; + + /** + * @param int $orderId + * @return $this + */ + public function setOrderId(int $orderId): self; + + /** + * @return string|null + */ + public function getAdjustmentId(): ?string; + + /** + * @param string $adjustmentId + * @return $this + */ + public function setAdjustmentId(string $adjustmentId): self; + + /** + * The adjustment as the agent submitted it, as JSON. + * + * @return string|null + */ + public function getPayload(): ?string; + + /** + * @param string $payload + * @return $this + */ + public function setPayload(string $payload): self; + + /** + * @return string|null + */ + public function getCreatedAt(): ?string; + + /** + * @return string|null + */ + public function getUpdatedAt(): ?string; +} diff --git a/Api/Data/TotalTypeInterface.php b/Api/Data/TotalTypeInterface.php new file mode 100644 index 0000000..fd75974 --- /dev/null +++ b/Api/Data/TotalTypeInterface.php @@ -0,0 +1,30 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Api\Data; + +/** + * The total types this module emits. UCP `2026-04-08` made `total.type` an open string — it documents + * these as well-known values in prose and lets businesses add their own — so the vocabulary is no + * longer generated from the schema and belongs to whoever writes the totals. + */ +interface TotalTypeInterface +{ + public const TYPE_SUBTOTAL = 'subtotal'; + public const TYPE_ITEMS_DISCOUNT = 'items_discount'; + public const TYPE_ITEMS_BASE_AMOUNT = 'items_base_amount'; + public const TYPE_DISCOUNT = 'discount'; + public const TYPE_FULFILLMENT = 'fulfillment'; + public const TYPE_TAX = 'tax'; + public const TYPE_FEE = 'fee'; + public const TYPE_TOTAL = 'total'; +} diff --git a/Api/IdempotencyKeyRepositoryInterface.php b/Api/IdempotencyKeyRepositoryInterface.php deleted file mode 100644 index 3e618b5..0000000 --- a/Api/IdempotencyKeyRepositoryInterface.php +++ /dev/null @@ -1,98 +0,0 @@ - - * @license MIT - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Api; - -use Magebit\UniversalCommerce\Api\Data\IdempotencyKeyInterface; -use Magento\Framework\Exception\CouldNotDeleteException; -use Magento\Framework\Exception\CouldNotSaveException; -use Magento\Framework\Exception\NoSuchEntityException; - -/** - * Repository Interface for Idempotency Key Model - * Provides CRUD operations and retrieval methods for idempotency key records - */ -interface IdempotencyKeyRepositoryInterface -{ - /** - * Save idempotency key record - * - * @param IdempotencyKeyInterface $idempotencyKey - * @return IdempotencyKeyInterface - * @throws CouldNotSaveException - */ - public function save(IdempotencyKeyInterface $idempotencyKey): IdempotencyKeyInterface; - - /** - * Get idempotency key record by ID - * - * @param int $entityId - * @return IdempotencyKeyInterface - * @throws NoSuchEntityException - */ - public function getById(int $entityId): IdempotencyKeyInterface; - - /** - * Get idempotency key record by key - * - * @param string $key - * @return IdempotencyKeyInterface - * @throws NoSuchEntityException - */ - public function getByKey(string $key): IdempotencyKeyInterface; - - /** - * Atomically claim a key so that only one concurrent caller executes the operation. - * - * @param string $key - * @param string $requestHash - * @return bool True when this caller won the claim. - */ - public function claim(string $key, string $requestHash): bool; - - /** - * Take over a claim whose owner died before storing a response. - * - * @param string $key - * @param string $requestHash - * @param string $abandonedBefore UTC datetime; claims created before it are considered abandoned. - * @return bool True when this caller took the claim over. - */ - public function reclaimAbandoned(string $key, string $requestHash, string $abandonedBefore): bool; - - /** - * Delete records created before the given point in time. - * - * @param string $expiredBefore UTC datetime. - * @return int Number of deleted rows. - */ - public function deleteExpired(string $expiredBefore): int; - - /** - * Delete idempotency key record - * - * @param IdempotencyKeyInterface $idempotencyKey - * @return bool - * @throws CouldNotDeleteException - */ - public function delete(IdempotencyKeyInterface $idempotencyKey): bool; - - /** - * Delete idempotency key record by ID - * - * @param int $entityId - * @return bool - * @throws CouldNotDeleteException - * @throws NoSuchEntityException - */ - public function deleteById(int $entityId): bool; -} diff --git a/Api/OrderAdjustmentRepositoryInterface.php b/Api/OrderAdjustmentRepositoryInterface.php new file mode 100644 index 0000000..705a67c --- /dev/null +++ b/Api/OrderAdjustmentRepositoryInterface.php @@ -0,0 +1,37 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Api; + +use Magebit\UniversalCommerce\Api\Data\OrderAdjustmentInterface; +use Magento\Framework\Exception\CouldNotSaveException; + +interface OrderAdjustmentRepositoryInterface +{ + /** + * Records the adjustment, replacing any earlier submission carrying the same identifier: a repeated + * request must not add a second copy of the same adjustment. + * + * @param int $orderId + * @param string $adjustmentId + * @param string $payload + * @return OrderAdjustmentInterface + * @throws CouldNotSaveException + */ + public function record(int $orderId, string $adjustmentId, string $payload): OrderAdjustmentInterface; + + /** + * @param int $orderId + * @return OrderAdjustmentInterface[] Oldest first, so the order they were submitted in is kept + */ + public function getByOrderId(int $orderId): array; +} diff --git a/Api/Service/Shopping/BuyerWithConsentInterface.php b/Api/Service/Shopping/BuyerWithConsentInterface.php new file mode 100644 index 0000000..436c81c --- /dev/null +++ b/Api/Service/Shopping/BuyerWithConsentInterface.php @@ -0,0 +1,36 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Api\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseConsentInterface; +use Magebit\UcpSpec\Api\Shopping\Types\BuyerInterface; + +/** + * The buyer plus the consent the `buyer_consent` capability hangs off it. The specification ships that + * capability as its own schema, so nothing generated composes the two into one type. + */ +interface BuyerWithConsentInterface extends BuyerInterface +{ + /** + * The consent object is the same four flags in every direction, so the response's type stands for + * all of them rather than the create and update copies being repeated here. + * + * @return \Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseConsentInterface|null + */ + public function getConsent(): ?BuyerConsentResponseConsentInterface; + + /** + * @param \Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseConsentInterface|null $consent + * @return self + */ + public function setConsent(?BuyerConsentResponseConsentInterface $consent): self; +} diff --git a/Api/Service/Shopping/CartHandlerInterface.php b/Api/Service/Shopping/CartHandlerInterface.php new file mode 100644 index 0000000..789b42f --- /dev/null +++ b/Api/Service/Shopping/CartHandlerInterface.php @@ -0,0 +1,47 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Api\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\CartResponseInterface; + +/** + * The cart capability's own endpoints. A cart has no complete operation: it is pre-purchase + * exploration, and the spec gives it none. + */ +interface CartHandlerInterface +{ + /** + * @param CheckoutCreateRequestInterface $request + * @return CartResponseInterface + */ + public function createCart(CheckoutCreateRequestInterface $request): CartResponseInterface; + + /** + * @param string $cartId + * @return CartResponseInterface + */ + public function getCart(string $cartId): CartResponseInterface; + + /** + * @param string $cartId + * @param CheckoutUpdateRequestInterface $request + * @return CartResponseInterface + */ + public function updateCart(string $cartId, CheckoutUpdateRequestInterface $request): CartResponseInterface; + + /** + * @param string $cartId + * @return CartResponseInterface + */ + public function cancelCart(string $cartId): CartResponseInterface; +} diff --git a/Api/Service/Shopping/CatalogHandlerInterface.php b/Api/Service/Shopping/CatalogHandlerInterface.php new file mode 100644 index 0000000..3da436d --- /dev/null +++ b/Api/Service/Shopping/CatalogHandlerInterface.php @@ -0,0 +1,43 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Api\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\CatalogLookupGetProductResponseInterface; +use Magebit\UcpSpec\Api\Shopping\CatalogLookupLookupResponseInterface; +use Magebit\UcpSpec\Api\Shopping\CatalogSearchSearchResponseInterface; + +/** + * The catalog search and lookup capabilities. Both are read-only and hold no session. + */ +interface CatalogHandlerInterface +{ + /** + * @param string|null $query Free-text query + * @param int|null $limit + * @param string|null $cursor Opaque page cursor the previous response returned + * @return CatalogSearchSearchResponseInterface + */ + public function search(?string $query, ?int $limit = null, ?string $cursor = null): CatalogSearchSearchResponseInterface; + + /** + * @param string[] $ids SKUs + * @return CatalogLookupLookupResponseInterface + */ + public function lookup(array $ids): CatalogLookupLookupResponseInterface; + + /** + * @param string $id SKU + * @return CatalogLookupGetProductResponseInterface + */ + public function getProduct(string $id): CatalogLookupGetProductResponseInterface; +} diff --git a/Api/Service/Shopping/CheckoutCreateRequestInterface.php b/Api/Service/Shopping/CheckoutCreateRequestInterface.php index d5cc5b3..be2a002 100644 --- a/Api/Service/Shopping/CheckoutCreateRequestInterface.php +++ b/Api/Service/Shopping/CheckoutCreateRequestInterface.php @@ -14,9 +14,17 @@ use Magebit\UcpSpec\Api\Shopping\CheckoutCreateRequestInterface as BaseRequestInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentRequestInterface; use Magebit\UcpSpec\Api\Shopping\DiscountResponseDiscountsObjectInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface; interface CheckoutCreateRequestInterface extends BaseRequestInterface { + /** + * Narrowed so the buyer arrives carrying the consent the `buyer_consent` capability adds to it. + * + * @return \Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface|null + */ + public function getBuyer(): ?BuyerWithConsentInterface; + /** * Get fulfillment request * diff --git a/Api/Service/Shopping/CheckoutResponseInterface.php b/Api/Service/Shopping/CheckoutResponseInterface.php index 478a007..b359331 100644 --- a/Api/Service/Shopping/CheckoutResponseInterface.php +++ b/Api/Service/Shopping/CheckoutResponseInterface.php @@ -15,8 +15,8 @@ use Magebit\UcpSpec\Api\Shopping\FulfillmentResponseCheckoutInterface; /** - * The checkout response carrying both extensions we implement. UCP composes each extension onto the - * base checkout separately, so no single spec type declares fulfillment and discounts together. + * The checkout response carrying every extension we implement. UCP composes each extension onto the + * base checkout separately, so no single spec type declares them together. */ interface CheckoutResponseInterface extends FulfillmentResponseCheckoutInterface { @@ -30,4 +30,18 @@ public function getDiscounts(): ?DiscountResponseDiscountsObjectInterface; * @return self */ public function setDiscounts(?DiscountResponseDiscountsObjectInterface $discounts): self; + + /** + * Set only on the cart endpoints. The cart capability adds this to the checkout response rather + * than defining an envelope of its own. + * + * @return string|null + */ + public function getCartId(): ?string; + + /** + * @param string|null $cartId + * @return self + */ + public function setCartId(?string $cartId): self; } diff --git a/Api/Service/Shopping/CheckoutUpdateRequestInterface.php b/Api/Service/Shopping/CheckoutUpdateRequestInterface.php index f012656..e91336e 100644 --- a/Api/Service/Shopping/CheckoutUpdateRequestInterface.php +++ b/Api/Service/Shopping/CheckoutUpdateRequestInterface.php @@ -14,9 +14,17 @@ use Magebit\UcpSpec\Api\Shopping\CheckoutUpdateRequestInterface as BaseRequestInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentRequestInterface; use Magebit\UcpSpec\Api\Shopping\DiscountResponseDiscountsObjectInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface; interface CheckoutUpdateRequestInterface extends BaseRequestInterface { + /** + * Narrowed so the buyer arrives carrying the consent the `buyer_consent` capability adds to it. + * + * @return \Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface|null + */ + public function getBuyer(): ?BuyerWithConsentInterface; + /** * @return \Magebit\UcpSpec\Api\Shopping\Types\FulfillmentRequestInterface|null */ diff --git a/Api/Service/Shopping/OrderHandlerInterface.php b/Api/Service/Shopping/OrderHandlerInterface.php new file mode 100644 index 0000000..7423dfb --- /dev/null +++ b/Api/Service/Shopping/OrderHandlerInterface.php @@ -0,0 +1,39 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Api\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\OrderResponseInterface; +use Magebit\UcpSpec\Api\Shopping\OrderUpdateRequestInterface; +use Magento\Framework\Exception\LocalizedException; + +interface OrderHandlerInterface +{ + /** + * @param string $checkoutId Session that placed the order, which is how the order is addressed + * @return OrderResponseInterface + * @throws LocalizedException When that session placed no order this protocol may hand out + */ + public function getOrder(string $checkoutId): OrderResponseInterface; + + /** + * Records the post-order adjustments the request adds and reports the order as it now stands. + * + * @param string $checkoutId Session that placed the order, which is how the order is addressed + * @param OrderUpdateRequestInterface $request + * @return OrderResponseInterface + * @throws LocalizedException When that session placed no order this protocol may hand out + */ + public function updateOrder( + string $checkoutId, + OrderUpdateRequestInterface $request + ): OrderResponseInterface; +} diff --git a/Api/Service/Shopping/RestHandlerInterface.php b/Api/Service/Shopping/RestHandlerInterface.php index 3ec2c0e..f32f6d0 100644 --- a/Api/Service/Shopping/RestHandlerInterface.php +++ b/Api/Service/Shopping/RestHandlerInterface.php @@ -47,5 +47,14 @@ public function updateCheckout(string $checkoutId, CheckoutUpdateRequestInterfac * @param PaymentInterface $paymentData * @return CheckoutResponseInterface */ + /** + * The quote a session is held on. Exposed because the cart capability works the same quote through + * a different response shape. + * + * @param string $maskedCartId + * @return \Magento\Quote\Api\Data\CartInterface + */ + public function getCartByMaskedId(string $maskedCartId): \Magento\Quote\Api\Data\CartInterface; + public function completeCheckout(string $checkoutId, PaymentInterface $paymentData): CheckoutResponseInterface; } diff --git a/Api/UniversalCommerceProtocolInterface.php b/Api/UniversalCommerceProtocolInterface.php index 1729810..6c46380 100644 --- a/Api/UniversalCommerceProtocolInterface.php +++ b/Api/UniversalCommerceProtocolInterface.php @@ -7,11 +7,19 @@ * @author Magebit * @license MIT */ + declare(strict_types=1); namespace Magebit\UniversalCommerce\Api; interface UniversalCommerceProtocolInterface { - public const SPEC_VERSION = '2026-01-23'; + /** + * The version advertised in discovery, the merchant profile, both capability declarations and + * every checkout response. It must match the spec target of the installed spec library, which + * supplies the types those payloads are built from — agents negotiate on this string, so + * advertising anything else misrepresents what the endpoint actually speaks. + * `SpecTargetTest` fails if the two drift apart. + */ + public const SPEC_VERSION = '2026-04-08'; } diff --git a/Console/Command/DispatchWebhooks.php b/Console/Command/DispatchWebhooks.php new file mode 100644 index 0000000..a37138b --- /dev/null +++ b/Console/Command/DispatchWebhooks.php @@ -0,0 +1,72 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Console\Command; + +use Magebit\UniversalCommerce\Cron\DispatchWebhooks as DispatchDueWebhooks; +use Magento\Framework\App\Area; +use Magento\Framework\App\State; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Sends the order-event webhooks that are due now, instead of waiting for the next cron run. Useful + * when a delivery has to go out at once, and to a conformance run, which cannot wait a minute. + */ +class DispatchWebhooks extends Command +{ + private const COMMAND_NAME = 'magebit:ucp:dispatch-webhooks'; + + /** + * @param DispatchDueWebhooks $dispatchDueWebhooks + * @param State $state + * @param string|null $name + */ + public function __construct( + private readonly DispatchDueWebhooks $dispatchDueWebhooks, + private readonly State $state, + ?string $name = null + ) { + parent::__construct($name); + } + + /** + * @return void + */ + protected function configure(): void + { + $this->setName(self::COMMAND_NAME); + $this->setDescription('Send the order-event webhooks that are due now.'); + + parent::configure(); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $this->state->setAreaCode(Area::AREA_ADMINHTML); + } catch (\Magento\Framework\Exception\LocalizedException $exception) { + // Already set by another command in the same process; nothing to do. + } + + // The same work the cron does, so the two cannot answer differently. + $output->writeln(sprintf('delivered %d', $this->dispatchDueWebhooks->execute())); + + return Command::SUCCESS; + } +} diff --git a/Console/Command/SeedConformance.php b/Console/Command/SeedConformance.php new file mode 100644 index 0000000..d870202 --- /dev/null +++ b/Console/Command/SeedConformance.php @@ -0,0 +1,84 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Console\Command; + +use Magebit\UniversalCommerce\Model\Seed\ConformanceFixtures; +use Magento\Framework\App\Area; +use Magento\Framework\App\State; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Seeds the fixed catalogue and coupons the UCP conformance run expects. + */ +class SeedConformance extends Command +{ + private const COMMAND_NAME = 'magebit:seed:ucp-conformance'; + + /** + * @param ConformanceFixtures $fixtures + * @param State $state + * @param string|null $name + */ + public function __construct( + private readonly ConformanceFixtures $fixtures, + private readonly State $state, + ?string $name = null + ) { + parent::__construct($name); + } + + /** + * @return void + */ + protected function configure(): void + { + $this->setName(self::COMMAND_NAME); + $this->setDescription('Seed the fixed products and coupons the UCP conformance run expects.'); + + parent::configure(); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $this->state->setAreaCode(Area::AREA_ADMINHTML); + } catch (\Magento\Framework\Exception\LocalizedException $exception) { + // Already set by another command in the same process; nothing to do. + } + + try { + foreach ($this->fixtures->applyProducts() as $sku) { + $output->writeln('product ' . $sku); + } + + foreach ($this->fixtures->applyRules() as $code) { + $output->writeln('coupon ' . $code); + } + } catch (\Throwable $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln('Conformance fixtures are in place.'); + + return Command::SUCCESS; + } +} diff --git a/Controller/ApiController.php b/Controller/ApiController.php index d5a95f1..12c7d24 100644 --- a/Controller/ApiController.php +++ b/Controller/ApiController.php @@ -13,57 +13,63 @@ namespace Magebit\UniversalCommerce\Controller; use JsonSerializable; -use Magento\Framework\App\ActionInterface; -use Magento\Framework\App\CsrfAwareActionInterface; -use Magento\Framework\App\Request\InvalidRequestException; +use Magebit\AgenticCore\Controller\JsonController; use Magento\Framework\App\RequestInterface; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\DataObject; -use Magebit\UniversalCommerce\Model\Validation\RequestValidator; -use Magebit\UniversalCommerce\Model\Validation\ValidationResult; -use Magebit\UniversalCommerce\Model\RequestClassBuilder; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\UcpSpec\Api\Shopping\Types\ErrorResponseInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\UcpErrorInterface; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; use Magebit\UniversalCommerce\Exception\UcpException; -use Magento\Framework\App\Request\Http; +use Magebit\UniversalCommerce\Exception\UcpMessagesException; use Magento\Framework\Exception\LocalizedException; use Magebit\UniversalCommerce\Model\Config; use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; use Psr\Log\LoggerInterface; -abstract class ApiController implements ActionInterface, CsrfAwareActionInterface +abstract class ApiController extends JsonController { /** - * Envelope status for a transport-tier failure, as opposed to a business outcome reported at 200. + * Trace header the spec marks required on every request, and which responses echo back. */ - public const STATUS_REQUIRES_ESCALATION = 'requires_escalation'; + public const HEADER_REQUEST_ID = 'Request-Id'; /** - * Trace header the spec marks required on every request, and which responses echo back. + * Header the agent identifies itself with, and names the protocol version it expects. */ - public const HEADER_REQUEST_ID = 'Request-Id'; + public const HEADER_AGENT = 'UCP-Agent'; /** * @param JsonFactory $resultJsonFactory * @param RequestInterface $request * @param RequestValidator $requestValidator - * @param RequestClassBuilder $requestClassBuilder + * @param Hydrator $hydrator * @param Config $config * @param MessageErrorInterfaceFactory $messageFactory * @param IdempotencyHandler $idempotencyHandler * @param LoggerInterface $logger + * @param VersionNegotiator $versionNegotiator */ public function __construct( - protected readonly JsonFactory $resultJsonFactory, - protected readonly RequestInterface $request, + JsonFactory $resultJsonFactory, + RequestInterface $request, protected readonly RequestValidator $requestValidator, - protected readonly RequestClassBuilder $requestClassBuilder, + protected readonly Hydrator $hydrator, protected readonly Config $config, protected readonly MessageErrorInterfaceFactory $messageFactory, protected readonly IdempotencyHandler $idempotencyHandler, - protected readonly LoggerInterface $logger + protected readonly LoggerInterface $logger, + protected readonly VersionNegotiator $versionNegotiator ) { + parent::__construct($resultJsonFactory, $request); } /** @@ -76,17 +82,18 @@ public function errorBoundary(callable $callback): ResultJson { try { $this->assertRequestId(); + $this->assertSupportedVersion(); return $callback(); } catch (UcpException $e) { return $this->makeErrorResponse( - $e->getType(), - [$this->errorMessage($e->getTypeCode(), $e->getMessage())], + [$this->errorMessage($e->getErrorCode(), $e->getMessage())], $e->getStatusCode() ); + } catch (UcpMessagesException $e) { + return $this->makeErrorResponse($e->getMessages(), $e->getStatusCode()); } catch (LocalizedException $e) { return $this->makeErrorResponse( - self::STATUS_REQUIRES_ESCALATION, [$this->errorMessage('invalid_request', $e->getMessage())], 500 ); @@ -94,7 +101,6 @@ public function errorBoundary(callable $callback): ResultJson $this->logger->error($e->getMessage(), ['exception' => $e]); return $this->makeErrorResponse( - self::STATUS_REQUIRES_ESCALATION, [$this->errorMessage('server_error', 'An unexpected error occurred.')], 500 ); @@ -120,7 +126,7 @@ public function getAndValidateRequest(string $classType, callable $factory): mix } $requestObject = $factory(); - $this->requestClassBuilder->populateWithArray($requestObject, $rawData, $classType); + $this->hydrator->populateWithArray($requestObject, $rawData, $classType); return $requestObject; } @@ -133,12 +139,20 @@ public function getAndValidateRequest(string $classType, callable $factory): mix public function handleIdempotency(): ?ResultJson { try { + // Checked before a stored response can be replayed: an agent that cannot read this + // version's payloads cannot read the replayed one either. + $this->assertSupportedVersion(); + if ($idempotencyResponse = $this->idempotencyHandler->handle($this->getHttpRequest())) { return $idempotencyResponse; } + } catch (UcpException $e) { + return $this->makeErrorResponse( + [$this->errorMessage($e->getErrorCode(), $e->getMessage())], + $e->getStatusCode() + ); } catch (LocalizedException $e) { return $this->makeErrorResponse( - self::STATUS_REQUIRES_ESCALATION, [$this->errorMessage('invalid_request', $e->getMessage())], 400 ); @@ -162,11 +176,24 @@ public function validationResultToResponse(ValidationResult $validationResult): 'validation_error', (string)$content, MessageErrorInterface::SEVERITY_REQUIRES_BUYER_INPUT, - $path === '' ? null : (string)$path + $path === '' ? null : $this->jsonPath((string)$path) ); } - return $this->makeErrorResponse(self::STATUS_REQUIRES_ESCALATION, $messages, 400); + // 422 rather than 400: the body parsed, so the request is well formed and the objection is to + // what it says. + return $this->makeErrorResponse($messages, 422); + } + + /** + * @return void + * @throws UcpException If the agent asked for a version this store does not speak + */ + protected function assertSupportedVersion(): void + { + $header = $this->getHttpRequest()->getHeader(self::HEADER_AGENT); + + $this->versionNegotiator->assertSupported(is_string($header) ? $header : null); } /** @@ -184,7 +211,6 @@ protected function assertRequestId(): void throw new UcpException( __('The %1 header is required.', self::HEADER_REQUEST_ID), - self::STATUS_REQUIRES_ESCALATION, 'invalid_request', 400 ); @@ -193,13 +219,37 @@ protected function assertRequestId(): void if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $requestId) !== 1) { throw new UcpException( __('The %1 header must be a UUID.', self::HEADER_REQUEST_ID), - self::STATUS_REQUIRES_ESCALATION, 'invalid_request', 400 ); } } + /** + * Every session-scoped action needs the identifier the router captured, and reports its absence + * identically. Kept here so the four of them cannot drift into four different message shapes. + * + * @return ResultJson + */ + protected function missingCheckoutId(): ResultJson + { + return $this->makeErrorResponse( + [$this->errorMessage('invalid_request', 'A checkout session identifier is required.')], + 400 + ); + } + + /** + * @return ResultJson + */ + protected function missingCartId(): ResultJson + { + return $this->makeErrorResponse( + [$this->errorMessage('invalid_request', 'A cart identifier is required.')], + 400 + ); + } + /** * The spec requires type, code, content and severity on every error, so they are set here rather * than at each construction site. @@ -231,23 +281,27 @@ protected function errorMessage( } /** - * Make error response + * The envelope is `types/error_response.json`, which forbids additional properties: the status + * belongs to the UCP metadata, and the per-message severity says what the platform may do next. + * Keyed off the generated constants so a schema rename fails the build rather than the wire. * - * @param string $status - * @param array $messages + * @param array $messages * @param int $statusCode * @return ResultJson */ - public function makeErrorResponse(string $status, array $messages, int $statusCode = 400): ResultJson + public function makeErrorResponse(array $messages, int $statusCode = 400): ResultJson { return $this->makeJsonResponse([ - 'status' => $status, - 'messages' => $messages + ErrorResponseInterface::KEY_UCP => [ + UcpErrorInterface::KEY_VERSION => UniversalCommerceProtocolInterface::SPEC_VERSION, + UcpErrorInterface::KEY_STATUS => UcpErrorInterface::STATUS_ERROR, + ], + ErrorResponseInterface::KEY_MESSAGES => $messages, ], $statusCode); } /** - * Make JSON response + * Every response echoes the trace header the request came in with. * * @param array|DataObject|JsonSerializable $data * @param int $statusCode @@ -255,10 +309,7 @@ public function makeErrorResponse(string $status, array $messages, int $statusCo */ public function makeJsonResponse(array|DataObject|JsonSerializable $data, int $statusCode = 200): ResultJson { - $resultJson = $this->resultJsonFactory->create(); - $resultJson->setData($data); - $resultJson->setHttpResponseCode($statusCode); - + $resultJson = parent::makeJsonResponse($data, $statusCode); $requestId = $this->getHttpRequest()->getHeader(self::HEADER_REQUEST_ID); if (is_string($requestId) && $requestId !== '') { @@ -267,37 +318,4 @@ public function makeJsonResponse(array|DataObject|JsonSerializable $data, int $s return $resultJson; } - - /** - * @param RequestInterface $request - * @return InvalidRequestException|null - */ - public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException - { - return null; - } - - /** - * @param RequestInterface $request - * @return bool|null - */ - public function validateForCsrf(RequestInterface $request): ?bool - { - return true; - } - - /** - * @return Http - */ - public function getHttpRequest(): Http - { - /** @var Http $request */ - $request = $this->request; - - if (!$request instanceof Http) { - throw new LocalizedException(__('Invalid request')); - } - - return $request; - } } diff --git a/Controller/Router.php b/Controller/Router.php deleted file mode 100644 index ee6124a..0000000 --- a/Controller/Router.php +++ /dev/null @@ -1,183 +0,0 @@ - - * @copyright Copyright (c) Magebit, Ltd. (https://magebit.com) - * @license https://magebit.com/code-license - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Controller; - -use Magento\Framework\App\Action\Forward; -use Magento\Framework\App\ActionFactory; -use Magento\Framework\App\ActionInterface; -use Magento\Framework\App\Request\Http; -use Magento\Framework\App\RequestInterface; -use Magento\Framework\App\RouterInterface; - -/** - * Extensible Router for UCP Endpoints - * Routes are configured via di.xml for flexibility - */ -class Router implements RouterInterface -{ - /** - * @param ActionFactory $actionFactory - * @param array $routes - */ - public function __construct( - private readonly ActionFactory $actionFactory, - private readonly array $routes = [] - ) { - } - - /** - * Match request to configured routes - * - * @param RequestInterface $request - * @return ActionInterface|null - */ - public function match(RequestInterface $request): ?ActionInterface - { - /** @var Http $request */ - if ($this->alreadyProcessed($request)) { - return null; - } - - $path = trim($request->getPathInfo(), '/'); - $method = $request->getMethod(); - - // Iterate through configured routes - foreach ($this->routes as $route) { - // Validate route configuration - if (!$this->isValidRoute($route)) { - continue; - } - - // Check if HTTP method matches - if (!$this->matchMethod($route['method'], $method)) { - continue; - } - - // Check if path matches and extract parameters - $params = $this->matchPath($route['path'], $path); - if ($params === false) { - continue; - } - - // Route matched - set request attributes - $request->setModuleName('ucp'); - $request->setControllerName($route['controller']); - $request->setActionName($route['action']); - - // Set extracted parameters - foreach ($params as $key => $value) { - $request->setParam($key, $value); - } - - // @phpstan-ignore arguments.count - return $this->actionFactory->create(Forward::class, ['request' => $request]); - } - - return null; - } - - /** - * Match path pattern against request path and extract parameters - * - * @param string $pattern - * @param string $path - * @return array|false - */ - private function matchPath(string $pattern, string $path): array|false - { - // Convert pattern to regex with named groups - $regex = $this->patternToRegex($pattern); - - if (!preg_match($regex, $path, $matches)) { - return false; - } - - // Extract named parameters - $params = []; - foreach ($matches as $key => $value) { - if (is_string($key)) { - $params[$key] = $value; - } - } - - return $params; - } - - /** - * Convert path pattern to regex - * - * @param string $pattern - * @return string - */ - private function patternToRegex(string $pattern): string - { - // Escape special regex characters except {} - $regex = preg_quote($pattern, '#'); - - // Convert {param} to named capture groups - // \\\{ and \\\} are the escaped { } after preg_quote - $regex = preg_replace('/\\\{([a-zA-Z_][a-zA-Z0-9_]*)\\\}/', '(?P<$1>[^/]+)', $regex); - - return '#^' . $regex . '$#'; - } - - /** - * Check if HTTP method matches - * - * @param string $routeMethod - * @param string $requestMethod - * @return bool - */ - private function matchMethod(string $routeMethod, string $requestMethod): bool - { - // Wildcard matches any method - if ($routeMethod === '*') { - return true; - } - - // Exact match (case-insensitive) - return strcasecmp($routeMethod, $requestMethod) === 0; - } - - /** - * Validate route configuration - * - * @param mixed $route - * @return bool - */ - private function isValidRoute(mixed $route): bool - { - if (!is_array($route)) { - return false; - } - - $requiredKeys = ['path', 'method', 'controller', 'action']; - foreach ($requiredKeys as $key) { - if (!isset($route[$key]) || !is_string($route[$key])) { - return false; - } - } - - return true; - } - - /** - * Check if request was already processed - * - * @param RequestInterface $request - * @return bool - */ - private function alreadyProcessed(RequestInterface $request): bool - { - /** @var Http $request */ - return $request->getModuleName() === 'ucp'; - } -} diff --git a/Controller/Service/Shopping/Cancel.php b/Controller/Service/Shopping/Cancel.php index 20a11db..cf8fac1 100644 --- a/Controller/Service/Shopping/Cancel.php +++ b/Controller/Service/Shopping/Cancel.php @@ -16,12 +16,13 @@ use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Framework\App\RequestInterface; -use Magebit\UniversalCommerce\Model\Validation\RequestValidator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; use Magebit\UniversalCommerce\Api\Service\Shopping\RestHandlerInterface; -use Magebit\UniversalCommerce\Model\Validation\ValidationResult; -use Magebit\UniversalCommerce\Model\RequestClassBuilder; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magebit\AgenticCore\Model\Request\Hydrator; use Magebit\UniversalCommerce\Model\Config; use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; use Psr\Log\LoggerInterface; use JsonSerializable; use Magento\Framework\Exception\LocalizedException; @@ -32,22 +33,24 @@ public function __construct( JsonFactory $resultJsonFactory, RequestInterface $request, RequestValidator $requestValidator, - RequestClassBuilder $requestClassBuilder, + Hydrator $hydrator, Config $config, MessageErrorInterfaceFactory $messageFactory, IdempotencyHandler $idempotencyHandler, LoggerInterface $logger, + VersionNegotiator $versionNegotiator, protected readonly RestHandlerInterface $restHandler ) { parent::__construct( $resultJsonFactory, $request, $requestValidator, - $requestClassBuilder, + $hydrator, $config, $messageFactory, $idempotencyHandler, - $logger + $logger, + $versionNegotiator ); } @@ -60,13 +63,7 @@ public function execute(): ResultJson $checkoutId = $this->getHttpRequest()->getParam('checkout_id'); if (!$checkoutId) { - return $this->makeErrorResponse('requires_escalation', [ - $this->messageFactory->create(['data' => [ - 'type' => 'error', - 'code' => 'invalid_request', - 'message' => 'Checkout ID is required', - ]]) - ], 400); + return $this->missingCheckoutId(); } if ($idempotencyResponse = $this->handleIdempotency()) { diff --git a/Controller/Service/Shopping/Cart/Cancel.php b/Controller/Service/Shopping/Cart/Cancel.php new file mode 100644 index 0000000..c087378 --- /dev/null +++ b/Controller/Service/Shopping/Cart/Cancel.php @@ -0,0 +1,96 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping\Cart; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\CartHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + +class Cancel extends ApiController +{ + /** + * @param JsonFactory $resultJsonFactory + * @param RequestInterface $request + * @param RequestValidator $requestValidator + * @param Hydrator $hydrator + * @param Config $config + * @param MessageErrorInterfaceFactory $messageFactory + * @param IdempotencyHandler $idempotencyHandler + * @param LoggerInterface $logger + * @param CartHandlerInterface $cartHandler + */ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + protected readonly CartHandlerInterface $cartHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + /** @var string|null $cartId */ + $cartId = $this->getHttpRequest()->getParam('cart_id'); + + if (!$cartId) { + return $this->missingCartId(); + } + + if ($idempotencyResponse = $this->handleIdempotency()) { + return $idempotencyResponse; + } + + return $this->errorBoundary(function () use ($cartId) { + $response = $this->cartHandler->cancelCart($cartId); + + if ($response instanceof JsonSerializable) { + $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $response, 200); + + return $this->makeJsonResponse($response); + } + + throw new LocalizedException(__('Internal server error')); + }); + } +} diff --git a/Controller/Service/Shopping/Cart/Create.php b/Controller/Service/Shopping/Cart/Create.php new file mode 100644 index 0000000..5a0bfa4 --- /dev/null +++ b/Controller/Service/Shopping/Cart/Create.php @@ -0,0 +1,104 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping\Cart; + +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutCreateRequestInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutCreateRequestInterfaceFactory; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\CartHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + +class Create extends ApiController +{ + /** + * @param JsonFactory $resultJsonFactory + * @param RequestInterface $request + * @param RequestValidator $requestValidator + * @param Hydrator $hydrator + * @param Config $config + * @param MessageErrorInterfaceFactory $messageFactory + * @param IdempotencyHandler $idempotencyHandler + * @param LoggerInterface $logger + * @param CheckoutCreateRequestInterfaceFactory $cartCreateRequestFactory + * @param CartHandlerInterface $cartHandler + */ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + protected readonly CheckoutCreateRequestInterfaceFactory $cartCreateRequestFactory, + protected readonly CartHandlerInterface $cartHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + // The cart create body is a subset of the checkout one, so the same request class carries it. + $request = $this->getAndValidateRequest( + CheckoutCreateRequestInterface::class, + $this->cartCreateRequestFactory->create(...) + ); + + if ($request instanceof ValidationResult) { + return $this->validationResultToResponse($request); + } + + if ($idempotencyResponse = $this->handleIdempotency()) { + return $idempotencyResponse; + } + + return $this->errorBoundary(function () use ($request) { + $response = $this->cartHandler->createCart($request); + + if ($response instanceof JsonSerializable) { + $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $response, 201); + + return $this->makeJsonResponse($response, 201); + } + + throw new LocalizedException(__('Internal server error')); + }); + } +} diff --git a/Controller/Service/Shopping/Cart/Get.php b/Controller/Service/Shopping/Cart/Get.php new file mode 100644 index 0000000..45a91e3 --- /dev/null +++ b/Controller/Service/Shopping/Cart/Get.php @@ -0,0 +1,96 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping\Cart; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\CartHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + +class Get extends ApiController +{ + /** + * @param JsonFactory $resultJsonFactory + * @param RequestInterface $request + * @param RequestValidator $requestValidator + * @param Hydrator $hydrator + * @param Config $config + * @param MessageErrorInterfaceFactory $messageFactory + * @param IdempotencyHandler $idempotencyHandler + * @param LoggerInterface $logger + * @param CartHandlerInterface $cartHandler + */ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + protected readonly CartHandlerInterface $cartHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + /** @var string|null $cartId */ + $cartId = $this->getHttpRequest()->getParam('cart_id'); + + if (!$cartId) { + return $this->missingCartId(); + } + + if ($idempotencyResponse = $this->handleIdempotency()) { + return $idempotencyResponse; + } + + return $this->errorBoundary(function () use ($cartId) { + $response = $this->cartHandler->getCart($cartId); + + if ($response instanceof JsonSerializable) { + $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $response, 200); + + return $this->makeJsonResponse($response); + } + + throw new LocalizedException(__('Internal server error')); + }); + } +} diff --git a/Controller/Service/Shopping/Cart/Update.php b/Controller/Service/Shopping/Cart/Update.php new file mode 100644 index 0000000..cb09b67 --- /dev/null +++ b/Controller/Service/Shopping/Cart/Update.php @@ -0,0 +1,110 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping\Cart; + +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutUpdateRequestInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutUpdateRequestInterfaceFactory; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\CartHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + +class Update extends ApiController +{ + /** + * @param JsonFactory $resultJsonFactory + * @param RequestInterface $request + * @param RequestValidator $requestValidator + * @param Hydrator $hydrator + * @param Config $config + * @param MessageErrorInterfaceFactory $messageFactory + * @param IdempotencyHandler $idempotencyHandler + * @param LoggerInterface $logger + * @param CheckoutUpdateRequestInterfaceFactory $cartUpdateRequestFactory + * @param CartHandlerInterface $cartHandler + */ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + protected readonly CheckoutUpdateRequestInterfaceFactory $cartUpdateRequestFactory, + protected readonly CartHandlerInterface $cartHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + /** @var string|null $cartId */ + $cartId = $this->getHttpRequest()->getParam('cart_id'); + + if (!$cartId) { + return $this->missingCartId(); + } + + $request = $this->getAndValidateRequest( + CheckoutUpdateRequestInterface::class, + $this->cartUpdateRequestFactory->create(...) + ); + + if ($request instanceof ValidationResult) { + return $this->validationResultToResponse($request); + } + + if ($idempotencyResponse = $this->handleIdempotency()) { + return $idempotencyResponse; + } + + return $this->errorBoundary(function () use ($cartId, $request) { + $response = $this->cartHandler->updateCart($cartId, $request); + + if ($response instanceof JsonSerializable) { + $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $response, 200); + + return $this->makeJsonResponse($response); + } + + throw new LocalizedException(__('Internal server error')); + }); + } +} diff --git a/Controller/Service/Shopping/Catalog/Lookup.php b/Controller/Service/Shopping/Catalog/Lookup.php new file mode 100644 index 0000000..42aa0e8 --- /dev/null +++ b/Controller/Service/Shopping/Catalog/Lookup.php @@ -0,0 +1,90 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping\Catalog; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\CatalogHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + + +class Lookup extends ApiController +{ + /** + * @param JsonFactory $resultJsonFactory + * @param RequestInterface $request + * @param RequestValidator $requestValidator + * @param Hydrator $hydrator + * @param Config $config + * @param MessageErrorInterfaceFactory $messageFactory + * @param IdempotencyHandler $idempotencyHandler + * @param LoggerInterface $logger + * @param CatalogHandlerInterface $catalogHandler + */ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + protected readonly CatalogHandlerInterface $catalogHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + return $this->errorBoundary(function (): ResultJson { + $payload = $this->decodedBody(); + $ids = is_array($payload['ids'] ?? null) ? $payload['ids'] : []; + + $response = $this->catalogHandler->lookup(array_values(array_filter( + array_map(static fn (mixed $id): string => is_scalar($id) ? (string) $id : '', $ids), + static fn (string $id): bool => $id !== '' + ))); + + if ($response instanceof JsonSerializable) { + return $this->makeJsonResponse($response); + } + + throw new LocalizedException(__('Internal server error')); + }); + } +} diff --git a/Controller/Service/Shopping/Catalog/Product.php b/Controller/Service/Shopping/Catalog/Product.php new file mode 100644 index 0000000..25c5ba0 --- /dev/null +++ b/Controller/Service/Shopping/Catalog/Product.php @@ -0,0 +1,96 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping\Catalog; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\CatalogHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + + +class Product extends ApiController +{ + /** + * @param JsonFactory $resultJsonFactory + * @param RequestInterface $request + * @param RequestValidator $requestValidator + * @param Hydrator $hydrator + * @param Config $config + * @param MessageErrorInterfaceFactory $messageFactory + * @param IdempotencyHandler $idempotencyHandler + * @param LoggerInterface $logger + * @param CatalogHandlerInterface $catalogHandler + */ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + protected readonly CatalogHandlerInterface $catalogHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + return $this->errorBoundary(function (): ResultJson { + $payload = $this->decodedBody(); + $raw = $payload['id'] ?? null; + $id = is_scalar($raw) ? (string) $raw : ''; + + if ($id === '') { + throw new \Magebit\UniversalCommerce\Exception\UcpException( + __('A product identifier is required.'), + 'invalid_request', + 400 + ); + } + + $response = $this->catalogHandler->getProduct($id); + + if ($response instanceof JsonSerializable) { + return $this->makeJsonResponse($response); + } + + throw new LocalizedException(__('Internal server error')); + }); + } +} diff --git a/Controller/Service/Shopping/Catalog/Search.php b/Controller/Service/Shopping/Catalog/Search.php new file mode 100644 index 0000000..82fbbb3 --- /dev/null +++ b/Controller/Service/Shopping/Catalog/Search.php @@ -0,0 +1,94 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping\Catalog; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\CatalogHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + + +class Search extends ApiController +{ + /** + * @param JsonFactory $resultJsonFactory + * @param RequestInterface $request + * @param RequestValidator $requestValidator + * @param Hydrator $hydrator + * @param Config $config + * @param MessageErrorInterfaceFactory $messageFactory + * @param IdempotencyHandler $idempotencyHandler + * @param LoggerInterface $logger + * @param CatalogHandlerInterface $catalogHandler + */ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + protected readonly CatalogHandlerInterface $catalogHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + return $this->errorBoundary(function (): ResultJson { + $payload = $this->decodedBody(); + $pagination = is_array($payload['pagination'] ?? null) ? $payload['pagination'] : []; + $limit = $pagination['limit'] ?? null; + $cursor = $pagination['cursor'] ?? null; + $query = $payload['query'] ?? null; + + $response = $this->catalogHandler->search( + is_string($query) ? $query : null, + is_numeric($limit) ? (int) $limit : null, + is_string($cursor) ? $cursor : null + ); + + if ($response instanceof JsonSerializable) { + return $this->makeJsonResponse($response); + } + + throw new LocalizedException(__('Internal server error')); + }); + } +} diff --git a/Controller/Service/Shopping/Complete.php b/Controller/Service/Shopping/Complete.php index 1162efb..2d7c95f 100644 --- a/Controller/Service/Shopping/Complete.php +++ b/Controller/Service/Shopping/Complete.php @@ -18,12 +18,14 @@ use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Framework\App\RequestInterface; -use Magebit\UniversalCommerce\Model\Validation\RequestValidator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; use Magebit\UniversalCommerce\Api\Service\Shopping\RestHandlerInterface; -use Magebit\UniversalCommerce\Model\Validation\ValidationResult; -use Magebit\UniversalCommerce\Model\RequestClassBuilder; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magebit\AgenticCore\Model\Request\Hydrator; use Magebit\UniversalCommerce\Model\Config; use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\UndeclaredExtensions; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; use Psr\Log\LoggerInterface; use JsonSerializable; use Magento\Framework\Exception\LocalizedException; @@ -34,11 +36,13 @@ public function __construct( JsonFactory $resultJsonFactory, RequestInterface $request, RequestValidator $requestValidator, - RequestClassBuilder $requestClassBuilder, + Hydrator $hydrator, Config $config, MessageErrorInterfaceFactory $messageFactory, IdempotencyHandler $idempotencyHandler, LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + private readonly UndeclaredExtensions $undeclaredExtensions, protected readonly RestHandlerInterface $restHandler, protected readonly CheckoutCompleteRequestInterfaceFactory $completeRequestFactory ) { @@ -46,11 +50,12 @@ public function __construct( $resultJsonFactory, $request, $requestValidator, - $requestClassBuilder, + $hydrator, $config, $messageFactory, $idempotencyHandler, - $logger + $logger, + $versionNegotiator ); } @@ -63,13 +68,7 @@ public function execute(): ResultJson $checkoutId = $this->getHttpRequest()->getParam('checkout_id'); if (!$checkoutId) { - return $this->makeErrorResponse('requires_escalation', [ - $this->messageFactory->create(['data' => [ - 'type' => 'error', - 'code' => 'invalid_request', - 'message' => 'Checkout ID is required', - ]]) - ], 400); + return $this->missingCheckoutId(); } // The body is {"payment": {...}} — the payment object is nested, not the root. @@ -92,7 +91,9 @@ public function execute(): ResultJson $completeCheckoutResponse = $this->restHandler->completeCheckout($checkoutId, $paymentData); if ($completeCheckoutResponse instanceof JsonSerializable) { - $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $completeCheckoutResponse, 201); + $this->undeclaredExtensions->annotate($completeCheckoutResponse, $this->decodedBody()); + + $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $completeCheckoutResponse, 200); return $this->makeJsonResponse($completeCheckoutResponse); } diff --git a/Controller/Service/Shopping/Create.php b/Controller/Service/Shopping/Create.php index 5b1806e..f90ba11 100644 --- a/Controller/Service/Shopping/Create.php +++ b/Controller/Service/Shopping/Create.php @@ -18,12 +18,14 @@ use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Framework\App\RequestInterface; -use Magebit\UniversalCommerce\Model\Validation\RequestValidator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; use Magebit\UniversalCommerce\Api\Service\Shopping\RestHandlerInterface; -use Magebit\UniversalCommerce\Model\Validation\ValidationResult; -use Magebit\UniversalCommerce\Model\RequestClassBuilder; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magebit\AgenticCore\Model\Request\Hydrator; use Magebit\UniversalCommerce\Model\Config; use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\UndeclaredExtensions; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; use Psr\Log\LoggerInterface; use JsonSerializable; use Magento\Framework\Exception\LocalizedException; @@ -34,11 +36,13 @@ public function __construct( JsonFactory $resultJsonFactory, RequestInterface $request, RequestValidator $requestValidator, - RequestClassBuilder $requestClassBuilder, + Hydrator $hydrator, Config $config, MessageErrorInterfaceFactory $messageFactory, IdempotencyHandler $idempotencyHandler, LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + private readonly UndeclaredExtensions $undeclaredExtensions, protected readonly CheckoutCreateRequestInterfaceFactory $checkoutCreateRequestFactory, protected readonly RestHandlerInterface $restHandler ) { @@ -46,11 +50,12 @@ public function __construct( $resultJsonFactory, $request, $requestValidator, - $requestClassBuilder, + $hydrator, $config, $messageFactory, $idempotencyHandler, - $logger + $logger, + $versionNegotiator ); } @@ -76,6 +81,8 @@ public function execute(): ResultJson $checkoutResponse = $this->restHandler->createCheckout($checkoutCreateRequest); if ($checkoutResponse instanceof JsonSerializable) { + $this->undeclaredExtensions->annotate($checkoutResponse, $this->decodedBody()); + $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $checkoutResponse, 201); return $this->makeJsonResponse($checkoutResponse, 201); diff --git a/Controller/Service/Shopping/Get.php b/Controller/Service/Shopping/Get.php index da13d52..594426f 100644 --- a/Controller/Service/Shopping/Get.php +++ b/Controller/Service/Shopping/Get.php @@ -16,12 +16,13 @@ use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Framework\App\RequestInterface; -use Magebit\UniversalCommerce\Model\Validation\RequestValidator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; use Magebit\UniversalCommerce\Api\Service\Shopping\RestHandlerInterface; -use Magebit\UniversalCommerce\Model\Validation\ValidationResult; -use Magebit\UniversalCommerce\Model\RequestClassBuilder; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magebit\AgenticCore\Model\Request\Hydrator; use Magebit\UniversalCommerce\Model\Config; use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; use Psr\Log\LoggerInterface; use JsonSerializable; use Magento\Framework\Exception\LocalizedException; @@ -32,22 +33,24 @@ public function __construct( JsonFactory $resultJsonFactory, RequestInterface $request, RequestValidator $requestValidator, - RequestClassBuilder $requestClassBuilder, + Hydrator $hydrator, Config $config, MessageErrorInterfaceFactory $messageFactory, IdempotencyHandler $idempotencyHandler, LoggerInterface $logger, + VersionNegotiator $versionNegotiator, protected readonly RestHandlerInterface $restHandler ) { parent::__construct( $resultJsonFactory, $request, $requestValidator, - $requestClassBuilder, + $hydrator, $config, $messageFactory, $idempotencyHandler, - $logger + $logger, + $versionNegotiator ); } @@ -60,13 +63,7 @@ public function execute(): ResultJson $checkoutId = $this->getHttpRequest()->getParam('checkout_id'); if (!$checkoutId) { - return $this->makeErrorResponse('requires_escalation', [ - $this->messageFactory->create(['data' => [ - 'type' => 'error', - 'code' => 'invalid_request', - 'message' => 'Checkout ID is required', - ]]) - ], 400); + return $this->missingCheckoutId(); } if ($idempotencyResponse = $this->handleIdempotency()) { diff --git a/Controller/Service/Shopping/GetOrder.php b/Controller/Service/Shopping/GetOrder.php new file mode 100644 index 0000000..bca6ff8 --- /dev/null +++ b/Controller/Service/Shopping/GetOrder.php @@ -0,0 +1,83 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\OrderHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + +class GetOrder extends ApiController +{ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + private readonly OrderHandlerInterface $orderHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + /** @var string|null $orderId */ + $orderId = $this->getHttpRequest()->getParam('order_id'); + + if (!$orderId) { + return $this->makeErrorResponse( + [$this->errorMessage('invalid_request', 'An order identifier is required.')], + 400 + ); + } + + // No idempotency handling: the read is idempotent by nature, and a stored replay would serve a + // stale snapshot of an order that keeps changing after it is placed. + return $this->errorBoundary(function () use ($orderId): ResultJson { + $order = $this->orderHandler->getOrder($orderId); + + if (!$order instanceof JsonSerializable) { + throw new LocalizedException(__('Internal server error')); + } + + return $this->makeJsonResponse($order); + }); + } +} diff --git a/Controller/Service/Shopping/Update.php b/Controller/Service/Shopping/Update.php index 4ed82d7..30e5906 100644 --- a/Controller/Service/Shopping/Update.php +++ b/Controller/Service/Shopping/Update.php @@ -18,12 +18,14 @@ use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Framework\App\RequestInterface; -use Magebit\UniversalCommerce\Model\Validation\RequestValidator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; use Magebit\UniversalCommerce\Api\Service\Shopping\RestHandlerInterface; -use Magebit\UniversalCommerce\Model\Validation\ValidationResult; -use Magebit\UniversalCommerce\Model\RequestClassBuilder; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magebit\AgenticCore\Model\Request\Hydrator; use Magebit\UniversalCommerce\Model\Config; use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\UndeclaredExtensions; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; use Psr\Log\LoggerInterface; use JsonSerializable; use Magento\Framework\Exception\LocalizedException; @@ -34,11 +36,13 @@ public function __construct( JsonFactory $resultJsonFactory, RequestInterface $request, RequestValidator $requestValidator, - RequestClassBuilder $requestClassBuilder, + Hydrator $hydrator, Config $config, MessageErrorInterfaceFactory $messageFactory, IdempotencyHandler $idempotencyHandler, LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + private readonly UndeclaredExtensions $undeclaredExtensions, protected readonly CheckoutUpdateRequestInterfaceFactory $checkoutUpdateRequestFactory, protected readonly RestHandlerInterface $restHandler ) { @@ -46,11 +50,12 @@ public function __construct( $resultJsonFactory, $request, $requestValidator, - $requestClassBuilder, + $hydrator, $config, $messageFactory, $idempotencyHandler, - $logger + $logger, + $versionNegotiator ); } @@ -63,13 +68,7 @@ public function execute(): ResultJson $checkoutId = $this->getHttpRequest()->getParam('checkout_id'); if (!$checkoutId) { - return $this->makeErrorResponse('requires_escalation', [ - $this->messageFactory->create(['data' => [ - 'type' => 'error', - 'code' => 'invalid_request', - 'message' => 'Checkout ID is required', - ]]) - ], 400); + return $this->missingCheckoutId(); } $checkoutUpdateRequest = $this->getAndValidateRequest( @@ -89,7 +88,9 @@ public function execute(): ResultJson $checkoutResponse = $this->restHandler->updateCheckout($checkoutId, $checkoutUpdateRequest); if ($checkoutResponse instanceof JsonSerializable) { - $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $checkoutResponse, 201); + $this->undeclaredExtensions->annotate($checkoutResponse, $this->decodedBody()); + + $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $checkoutResponse, 200); return $this->makeJsonResponse($checkoutResponse); } diff --git a/Controller/Service/Shopping/UpdateOrder.php b/Controller/Service/Shopping/UpdateOrder.php new file mode 100644 index 0000000..bb76510 --- /dev/null +++ b/Controller/Service/Shopping/UpdateOrder.php @@ -0,0 +1,100 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\OrderUpdateRequestInterface; +use Magebit\UcpSpec\Api\Shopping\OrderUpdateRequestInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\OrderHandlerInterface; +use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use Magebit\AgenticCore\Model\Validation\ValidationResult; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Framework\Exception\LocalizedException; +use JsonSerializable; +use Psr\Log\LoggerInterface; + +class UpdateOrder extends ApiController +{ + public function __construct( + JsonFactory $resultJsonFactory, + RequestInterface $request, + RequestValidator $requestValidator, + Hydrator $hydrator, + Config $config, + MessageErrorInterfaceFactory $messageFactory, + IdempotencyHandler $idempotencyHandler, + LoggerInterface $logger, + VersionNegotiator $versionNegotiator, + private readonly OrderUpdateRequestInterfaceFactory $orderUpdateRequestFactory, + private readonly OrderHandlerInterface $orderHandler + ) { + parent::__construct( + $resultJsonFactory, + $request, + $requestValidator, + $hydrator, + $config, + $messageFactory, + $idempotencyHandler, + $logger, + $versionNegotiator + ); + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + /** @var string|null $orderId */ + $orderId = $this->getHttpRequest()->getParam('order_id'); + + if (!$orderId) { + return $this->makeErrorResponse( + [$this->errorMessage('invalid_request', 'An order identifier is required.')], + 400 + ); + } + + $orderUpdateRequest = $this->getAndValidateRequest( + OrderUpdateRequestInterface::class, + $this->orderUpdateRequestFactory->create(...) + ); + + if ($orderUpdateRequest instanceof ValidationResult) { + return $this->validationResultToResponse($orderUpdateRequest); + } + + if ($idempotencyResponse = $this->handleIdempotency()) { + return $idempotencyResponse; + } + + return $this->errorBoundary(function () use ($orderId, $orderUpdateRequest): ResultJson { + $order = $this->orderHandler->updateOrder($orderId, $orderUpdateRequest); + + if (!$order instanceof JsonSerializable) { + throw new LocalizedException(__('Internal server error')); + } + + $this->idempotencyHandler->storeResponse($this->getHttpRequest(), $order, 200); + + return $this->makeJsonResponse($order); + }); + } +} diff --git a/Controller/Testing/SimulateShipping.php b/Controller/Testing/SimulateShipping.php new file mode 100644 index 0000000..be02372 --- /dev/null +++ b/Controller/Testing/SimulateShipping.php @@ -0,0 +1,148 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Controller\Testing; + +use Magebit\UniversalCommerce\Model\Order\SessionOrderLookup; +use Magebit\UniversalCommerce\Model\Simulation\Secret; +use Magento\Framework\App\ActionInterface; +use Magento\Framework\App\CsrfAwareActionInterface; +use Magento\Framework\App\Request\Http; +use Magento\Framework\App\Request\InvalidRequestException; +use Magento\Framework\App\RequestInterface; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Sales\Api\ShipOrderInterface; +use Psr\Log\LoggerInterface; + +/** + * Ships an agent-placed order so the conformance suite can watch the order event that follows. + * + * This is not part of the UCP specification. The conformance suite hardcodes this path and sends a + * shared secret, following the reference sample server. It stays switched off until a secret is put + * in app/etc/env.php, and it only ever ships orders that this protocol placed itself. + */ +class SimulateShipping implements ActionInterface, CsrfAwareActionInterface +{ + /** + * Header the conformance suite sends the shared secret in. + */ + public const HEADER_SECRET = 'Simulation-Secret'; + + /** + * @param JsonFactory $resultJsonFactory + * @param Http $request + * @param Secret $secret + * @param SessionOrderLookup $orderLookup + * @param ShipOrderInterface $shipOrder + * @param LoggerInterface $logger + */ + public function __construct( + private readonly JsonFactory $resultJsonFactory, + private readonly Http $request, + private readonly Secret $secret, + private readonly SessionOrderLookup $orderLookup, + private readonly ShipOrderInterface $shipOrder, + private readonly LoggerInterface $logger + ) { + } + + /** + * @return ResultJson + */ + public function execute(): ResultJson + { + // With no secret configured the endpoint behaves as if it were not routed at all, so nothing is + // exposed on a store that never opted in. + if (!$this->secret->isConfigured()) { + return $this->error(404, 'Not found.'); + } + + $sent = $this->request->getHeader(self::HEADER_SECRET); + + if (!$this->secret->matches(is_string($sent) ? $sent : null)) { + return $this->error(403, 'A valid simulation secret is required.'); + } + + // The same identifier the protocol hands out: the checkout session that placed the order. + /** @var string|null $checkoutId */ + $checkoutId = $this->request->getParam('order_id'); + $order = $this->orderLookup->find((string) $checkoutId); + $entityId = $order?->getEntityId(); + + // An order the protocol did not place is none of this endpoint's business, so it reads as + // missing rather than refused. + if ($order === null || !is_numeric($entityId)) { + return $this->error(404, 'Order not found.'); + } + + if (!$order->canShip()) { + return $this->error(409, 'This order cannot be shipped.'); + } + + try { + $shipmentId = $this->shipOrder->execute((int) $entityId); + } catch (\Throwable $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + + return $this->error(500, 'The order could not be shipped.'); + } + + return $this->json(200, [ + 'order_id' => (string) $checkoutId, + 'label' => $order->getIncrementId(), + 'shipment_id' => $shipmentId, + ]); + } + + /** + * @param int $statusCode + * @param string $message + * @return ResultJson + */ + private function error(int $statusCode, string $message): ResultJson + { + return $this->json($statusCode, ['message' => $message]); + } + + /** + * @param int $statusCode + * @param array $data + * @return ResultJson + */ + private function json(int $statusCode, array $data): ResultJson + { + $result = $this->resultJsonFactory->create(); + $result->setHttpResponseCode($statusCode); + $result->setData($data); + + return $result; + } + + /** + * @param RequestInterface $request + * @return InvalidRequestException|null + */ + public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException + { + return null; + } + + /** + * @param RequestInterface $request + * @return bool|null + */ + public function validateForCsrf(RequestInterface $request): ?bool + { + return true; + } +} diff --git a/Cron/DispatchWebhooks.php b/Cron/DispatchWebhooks.php new file mode 100644 index 0000000..c8a1741 --- /dev/null +++ b/Cron/DispatchWebhooks.php @@ -0,0 +1,45 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Cron; + +use Magebit\AgenticCore\Model\Webhook\DueDeliveries; +use Magebit\UniversalCommerce\Model\Config; + +/** + * Attempts this module's due order-event deliveries. + */ +class DispatchWebhooks +{ + /** + * @param DueDeliveries $dueDeliveries + * @param Config $config + */ + public function __construct( + private readonly DueDeliveries $dueDeliveries, + private readonly Config $config + ) { + } + + /** + * @return int How many deliveries went out + */ + public function execute(): int + { + // Checked before querying so a merchant who has not enabled delivery pays nothing for the cron. + if (!$this->config->areWebhooksEnabled()) { + return 0; + } + + return $this->dueDeliveries->execute(); + } +} diff --git a/Exception/UcpException.php b/Exception/UcpException.php index e19d33b..97388ab 100644 --- a/Exception/UcpException.php +++ b/Exception/UcpException.php @@ -14,20 +14,22 @@ use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Phrase; +/** + * A failure the boundary can report as the spec's error response: the error code names the failure and + * the HTTP status carries the transport tier. Named `errorCode` because `Exception::getCode()` is final + * and typed int. + */ class UcpException extends LocalizedException { public function __construct( Phrase $phrase, - public readonly string $type = 'error', - public readonly string $typeCode = 'requires_escalation', + public readonly string $errorCode = 'server_error', public readonly int $statusCode = 500 ) { parent::__construct($phrase); } /** - * Get status code - * * @return int */ public function getStatusCode(): int @@ -36,22 +38,10 @@ public function getStatusCode(): int } /** - * Get type code - * - * @return string - */ - public function getTypeCode(): string - { - return $this->typeCode; - } - - /** - * Get type - * * @return string */ - public function getType(): string + public function getErrorCode(): string { - return $this->type; + return $this->errorCode; } } diff --git a/Exception/UcpMessagesException.php b/Exception/UcpMessagesException.php new file mode 100644 index 0000000..8eb147f --- /dev/null +++ b/Exception/UcpMessagesException.php @@ -0,0 +1,52 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Exception; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magento\Framework\Exception\LocalizedException; +use Magento\Framework\Phrase; + +/** + * A failure with more than one thing to say: the boundary reports every message, so an agent is told + * each field it has to fix rather than only the first one the store tripped over. + */ +class UcpMessagesException extends LocalizedException +{ + /** + * @param Phrase $phrase + * @param MessageInterface[] $messages + * @param int $statusCode + */ + public function __construct( + Phrase $phrase, + public readonly array $messages, + public readonly int $statusCode = 422 + ) { + parent::__construct($phrase); + } + + /** + * @return MessageInterface[] + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * @return int + */ + public function getStatusCode(): int + { + return $this->statusCode; + } +} diff --git a/Model/CheckoutMeta/Model.php b/Model/CheckoutMeta/Model.php index e83f931..dc86ea0 100644 --- a/Model/CheckoutMeta/Model.php +++ b/Model/CheckoutMeta/Model.php @@ -121,6 +121,24 @@ public function setSubmittedFulfillment(?string $submittedFulfillment): Checkout return $this->setData(self::SUBMITTED_FULFILLMENT, $submittedFulfillment); } + /** + * @inheritDoc + */ + public function getBuyerConsent(): ?string + { + $value = $this->getData(self::BUYER_CONSENT); + + return is_string($value) ? $value : null; + } + + /** + * @inheritDoc + */ + public function setBuyerConsent(?string $buyerConsent): CheckoutMetaInterface + { + return $this->setData(self::BUYER_CONSENT, $buyerConsent); + } + /** * @inheritDoc */ diff --git a/Model/Config.php b/Model/Config.php index 8dfcd69..1616743 100644 --- a/Model/Config.php +++ b/Model/Config.php @@ -21,6 +21,7 @@ class Config private const XML_PATH_PAYMENT_METHOD = 'universal_commerce/checkout/payment_method'; private const XML_PATH_LINKS = 'universal_commerce/links'; private const XML_PATH_REQUIRE_REQUEST_ID = 'universal_commerce/api/require_request_id'; + private const XML_PATH_WEBHOOKS_ENABLED = 'universal_commerce/webhooks/enabled'; /** * Quote lifetime in days, which is what a checkout session's expiry is derived from. @@ -54,6 +55,23 @@ public function __construct( * @param int|null $storeId * @return string */ + /** + * @param int|null $storeId + * @return bool + */ + public function areWebhooksEnabled(?int $storeId = null): bool + { + return $this->scopeConfig->isSetFlag( + self::XML_PATH_WEBHOOKS_ENABLED, + ScopeInterface::SCOPE_STORE, + $storeId + ); + } + + /** + * @param int|null $storeId + * @return string + */ public function getApiBaseUrl(?int $storeId = null): string { /** @var string|null $baseUrl */ diff --git a/Model/Discovery/MerchantProfileBuilder.php b/Model/Discovery/MerchantProfileBuilder.php index d9f4ee6..59d753f 100644 --- a/Model/Discovery/MerchantProfileBuilder.php +++ b/Model/Discovery/MerchantProfileBuilder.php @@ -11,46 +11,87 @@ namespace Magebit\UniversalCommerce\Model\Discovery; -use Magebit\UcpSpec\Api\Discovery\ProfileSchemaInterface as DiscoveryProfileInterface; -use Magebit\UcpSpec\Api\Discovery\ProfileSchemaInterfaceFactory as DiscoveryProfileInterfaceFactory; +use Magebit\UcpSpec\Api\Discovery\ProfileSchemaInterface; +use Magebit\UcpSpec\Api\Discovery\ProfileSchemaInterfaceFactory; +use Magebit\UcpSpec\Api\Discovery\ProfileSchemaSigningKeyInterface; +use Magebit\UcpSpec\Api\Discovery\ProfileSchemaSigningKeyInterfaceFactory; -use Magebit\UcpSpec\Api\UcpBusinessSchemaInterface as UcpProfileInterface; -use Magebit\UcpSpec\Api\UcpBusinessSchemaInterfaceFactory as UcpProfileInterfaceFactory; +use Magebit\UcpSpec\Api\UcpBusinessSchemaInterface; +use Magebit\UcpSpec\Api\UcpBusinessSchemaInterfaceFactory; use Magebit\UcpSpec\Api\ServiceBusinessSchemaInterface; use Magebit\UcpSpec\Api\CapabilityBusinessSchemaInterface; +use Magebit\AgenticCore\Api\SigningKeyRepositoryInterface; use Magebit\UniversalCommerce\Api\ServiceInterface; use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; class MerchantProfileBuilder { /** - * @param DiscoveryProfileInterfaceFactory $discoveryProfileFactory - * @param UcpProfileInterfaceFactory $ucpProfileFactory + * One profile is served per site, so the key set lives in the default scope. + */ + private const KEY_STORE_ID = 0; + + /** + * @param ProfileSchemaInterfaceFactory $discoveryProfileFactory + * @param UcpBusinessSchemaInterfaceFactory $ucpProfileFactory * @param ServiceRegistry $serviceRegistry + * @param SigningKeyRepositoryInterface $signingKeys + * @param ProfileSchemaSigningKeyInterfaceFactory $jwkFactory */ public function __construct( - private readonly DiscoveryProfileInterfaceFactory $discoveryProfileFactory, - private readonly UcpProfileInterfaceFactory $ucpProfileFactory, + private readonly ProfileSchemaInterfaceFactory $discoveryProfileFactory, + private readonly UcpBusinessSchemaInterfaceFactory $ucpProfileFactory, private readonly ServiceRegistry $serviceRegistry, + private readonly SigningKeyRepositoryInterface $signingKeys, + private readonly ProfileSchemaSigningKeyInterfaceFactory $jwkFactory, ) { } /** - * @return DiscoveryProfileInterface + * @return ProfileSchemaInterface */ - public function build(): DiscoveryProfileInterface + public function build(): ProfileSchemaInterface { return $this->discoveryProfileFactory->create([ 'data' => [ - DiscoveryProfileInterface::KEY_UCP => $this->buildUcp(), + ProfileSchemaInterface::KEY_UCP => $this->buildUcp(), + ProfileSchemaInterface::KEY_SIGNING_KEYS => $this->buildSigningKeys(), ] ]); } /** - * @return UcpProfileInterface + * The public halves a platform needs to verify what we sign. Retired keys stay listed through their + * grace window, so a delivery signed just before a rotation still verifies. + * + * @return ProfileSchemaSigningKeyInterface[] + */ + private function buildSigningKeys(): array + { + // Generated here if it does not exist yet: an agent reads the profile before it ever receives a + // webhook, and a profile with no key gives it nothing to verify against later. + $this->signingKeys->getSigningKey(IdempotencyHandler::SCOPE, self::KEY_STORE_ID); + + $jwks = []; + + foreach ($this->signingKeys->getPublishableKeys(IdempotencyHandler::SCOPE, self::KEY_STORE_ID) as $key) { + $jwk = $key->getPublicJwk(); + + if ($jwk === [] || !isset($jwk[ProfileSchemaSigningKeyInterface::KEY_KID])) { + continue; + } + + $jwks[] = $this->jwkFactory->create(['data' => $jwk]); + } + + return $jwks; + } + + /** + * @return UcpBusinessSchemaInterface */ - public function buildUcp(): UcpProfileInterface + public function buildUcp(): UcpBusinessSchemaInterface { $registeredServices = $this->serviceRegistry->getServices(); $services = []; @@ -78,10 +119,10 @@ public function buildUcp(): UcpProfileInterface return $this->ucpProfileFactory->create([ 'data' => [ - UcpProfileInterface::KEY_VERSION => UniversalCommerceProtocolInterface::SPEC_VERSION, - UcpProfileInterface::KEY_SERVICES => $services, - UcpProfileInterface::KEY_CAPABILITIES => $capabilities, - UcpProfileInterface::KEY_PAYMENT_HANDLERS => [], + UcpBusinessSchemaInterface::KEY_VERSION => UniversalCommerceProtocolInterface::SPEC_VERSION, + UcpBusinessSchemaInterface::KEY_SERVICES => $services, + UcpBusinessSchemaInterface::KEY_CAPABILITIES => $capabilities, + UcpBusinessSchemaInterface::KEY_PAYMENT_HANDLERS => [], ] ]); } diff --git a/Model/IdempotencyHandler.php b/Model/IdempotencyHandler.php index 64c8e26..9e78290 100644 --- a/Model/IdempotencyHandler.php +++ b/Model/IdempotencyHandler.php @@ -13,30 +13,24 @@ namespace Magebit\UniversalCommerce\Model; use JsonSerializable; +use Magebit\AgenticCore\Model\Idempotency\DecisionOutcome; +use Magebit\AgenticCore\Model\Idempotency\Gate; use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; -use Magebit\UniversalCommerce\Api\Data\IdempotencyKeyInterface; -use Magebit\UniversalCommerce\Api\Data\IdempotencyKeyInterfaceFactory; -use Magebit\UniversalCommerce\Api\IdempotencyKeyRepositoryInterface; use Magento\Framework\App\Request\Http; use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory; -use Magento\Framework\Exception\NoSuchEntityException; -use Magento\Framework\Stdlib\DateTime\DateTime; +use Magento\Framework\Exception\CouldNotSaveException; +/** + * Maps the shared gate's decisions onto this protocol's envelope. The arbitration itself is shared. + */ class IdempotencyHandler { - public const HEADER_NAME = 'Idempotency-Key'; - - /** - * Replaying a safe method would hide current state, so idempotency applies to unsafe methods only. - */ - private const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE']; - /** - * A claim older than this without a stored response is treated as abandoned by a crashed request. + * Partitions this module's rows in the shared table. */ - private const ABANDONED_CLAIM_SECONDS = 60; + public const SCOPE = 'ucp'; /** * Advertised in Retry-After while another caller still owns the claim. @@ -44,142 +38,56 @@ class IdempotencyHandler private const RETRY_AFTER_SECONDS = 1; /** - * @param IdempotencyKeyRepositoryInterface $idempotencyRepository - * @param IdempotencyKeyInterfaceFactory $idempotencyKeyFactory + * @param Gate $gate * @param JsonFactory $resultJsonFactory * @param MessageInterfaceFactory $messageFactory - * @param DateTime $dateTime */ public function __construct( - protected readonly IdempotencyKeyRepositoryInterface $idempotencyRepository, - protected readonly IdempotencyKeyInterfaceFactory $idempotencyKeyFactory, + protected readonly Gate $gate, protected readonly JsonFactory $resultJsonFactory, - protected readonly MessageInterfaceFactory $messageFactory, - protected readonly DateTime $dateTime + protected readonly MessageInterfaceFactory $messageFactory ) { } /** - * Claim the key, or return the replay / conflict response the caller must send instead. - * * @param Http $request * @return ResultJson|null Null means the caller owns the key and must execute the operation. */ public function handle(Http $request): ?ResultJson { - $key = $this->getKey($request); - - if ($key === null) { - return null; - } - - $requestHash = $this->hashRequest($request); - - if ($this->idempotencyRepository->claim($key, $requestHash)) { - return null; - } - - try { - $idempotency = $this->idempotencyRepository->getByKey($key); - } catch (NoSuchEntityException $exception) { - // Purged between the failed claim and this read; nothing to replay. - return null; - } + $decision = $this->gate->decide(self::SCOPE, $request); - if ($idempotency->getRequestHash() !== $requestHash) { - return $this->makeConflictResponse( + return match ($decision->outcome) { + DecisionOutcome::Proceed, DecisionOutcome::KeyMissing => null, + DecisionOutcome::Conflict => $this->makeConflictResponse( 'idempotency_key_reuse', 'Idempotency-Key was already used for a different request.' - ); - } - - if ($idempotency->getResponseStatus() === null) { - return $this->handleInFlight($key, $requestHash); - } - - return $this->makeReplayResponse($idempotency); + ), + DecisionOutcome::InFlight => $this->makeInFlightResponse(), + DecisionOutcome::Replay => $this->makeReplayResponse( + (string) $decision->body, + (int) $decision->status + ), + }; } /** * @param Http $request * @param JsonSerializable $response * @param int $status - * @return IdempotencyKeyInterface|null + * @return void + * @throws CouldNotSaveException */ - public function storeResponse(Http $request, JsonSerializable $response, int $status): ?IdempotencyKeyInterface + public function storeResponse(Http $request, JsonSerializable $response, int $status): void { - $key = $this->getKey($request); - - if ($key === null) { - return null; - } - - try { - $idempotency = $this->idempotencyRepository->getByKey($key); - } catch (NoSuchEntityException $exception) { - /** @var IdempotencyKeyInterface $idempotency */ - $idempotency = $this->idempotencyKeyFactory->create(); - $idempotency->setKey($key); - } - - $idempotency->setRequestHash($this->hashRequest($request)); - $idempotency->setResponseBody((string) json_encode($response)); - $idempotency->setResponseStatus($status); - - $this->idempotencyRepository->save($idempotency); - - return $idempotency; + $this->gate->remember(self::SCOPE, $request, (string) json_encode($response), $status); } /** - * @param Http $request - * @return string - */ - protected function hashRequest(Http $request): string - { - return hash('sha256', (string) json_encode([ - 'method' => $request->getMethod(), - 'path' => $request->getPathInfo(), - 'query' => $request->getQuery(), - 'body' => $request->getContent(), - ])); - } - - /** - * @param Http $request - * @return string|null Null when idempotency does not apply to this request. - */ - protected function getKey(Http $request): ?string - { - if (in_array(strtoupper($request->getMethod()), self::SAFE_METHODS, true)) { - return null; - } - - $key = $request->getHeader(self::HEADER_NAME); - - if (!is_string($key) || $key === '') { - return null; - } - - return $key; - } - - /** - * @param string $key - * @param string $requestHash - * @return ResultJson|null Null when the abandoned claim was taken over. + * @return ResultJson */ - protected function handleInFlight(string $key, string $requestHash): ?ResultJson + protected function makeInFlightResponse(): ResultJson { - $abandonedBefore = $this->dateTime->gmtDate( - 'Y-m-d H:i:s', - $this->dateTime->gmtTimestamp() - self::ABANDONED_CLAIM_SECONDS - ); - - if ($this->idempotencyRepository->reclaimAbandoned($key, $requestHash, $abandonedBefore)) { - return null; - } - $result = $this->makeConflictResponse( 'idempotency_key_in_flight', 'A request with this Idempotency-Key is still being processed.' @@ -190,14 +98,15 @@ protected function handleInFlight(string $key, string $requestHash): ?ResultJson } /** - * @param IdempotencyKeyInterface $idempotency + * @param string $body + * @param int $status * @return ResultJson */ - protected function makeReplayResponse(IdempotencyKeyInterface $idempotency): ResultJson + protected function makeReplayResponse(string $body, int $status): ResultJson { $result = $this->resultJsonFactory->create(); - $result->setJsonData($idempotency->getResponseBody() ?? ''); - $result->setHttpResponseCode((int) $idempotency->getResponseStatus()); + $result->setJsonData($body); + $result->setHttpResponseCode($status); return $result; } diff --git a/Model/IdempotencyKey/Cleanup.php b/Model/IdempotencyKey/Cleanup.php index 744d8d5..fa4bbdf 100644 --- a/Model/IdempotencyKey/Cleanup.php +++ b/Model/IdempotencyKey/Cleanup.php @@ -12,26 +12,21 @@ namespace Magebit\UniversalCommerce\Model\IdempotencyKey; -use Magebit\UniversalCommerce\Api\IdempotencyKeyRepositoryInterface; +use Magebit\AgenticCore\Model\Idempotency\Purge; use Magebit\UniversalCommerce\Model\Config; -use Magento\Framework\Stdlib\DateTime\DateTime; /** - * Cron entry point that purges stored idempotent responses past their configured TTL. + * Cron entry point that purges stored idempotent responses past this module's configured TTL. */ class Cleanup { - private const SECONDS_PER_HOUR = 3600; - /** - * @param IdempotencyKeyRepositoryInterface $idempotencyRepository + * @param Purge $purge * @param Config $config - * @param DateTime $dateTime */ public function __construct( - private readonly IdempotencyKeyRepositoryInterface $idempotencyRepository, - private readonly Config $config, - private readonly DateTime $dateTime + private readonly Purge $purge, + private readonly Config $config ) { } @@ -40,17 +35,6 @@ public function __construct( */ public function execute(): int { - $ttlHours = $this->config->getIdempotencyTtlHours(); - - if ($ttlHours < 1) { - return 0; - } - - $expiredBefore = $this->dateTime->gmtDate( - 'Y-m-d H:i:s', - $this->dateTime->gmtTimestamp() - $ttlHours * self::SECONDS_PER_HOUR - ); - - return $this->idempotencyRepository->deleteExpired($expiredBefore); + return $this->purge->execute($this->config->getIdempotencyTtlHours()); } } diff --git a/Model/IdempotencyKey/Model.php b/Model/IdempotencyKey/Model.php deleted file mode 100644 index 3447671..0000000 --- a/Model/IdempotencyKey/Model.php +++ /dev/null @@ -1,138 +0,0 @@ - - * @license MIT - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Model\IdempotencyKey; - -use Magebit\UniversalCommerce\Api\Data\IdempotencyKeyInterface; -use Magento\Framework\Model\AbstractModel; - -/** - * Idempotency Key Model - */ -class Model extends AbstractModel implements IdempotencyKeyInterface -{ - /** - * Initialize resource model - * - * @return void - */ - protected function _construct(): void - { - $this->_init(ResourceModel::class); - } - - /** - * @inheritDoc - */ - public function getEntityId(): ?int - { - $value = $this->getData(self::ENTITY_ID); - return $value !== null && $value !== '' ? (int) $value : null; - } - - /** - * @inheritDoc - */ - public function getKey(): ?string - { - return $this->getData(self::KEY); - } - - /** - * @inheritDoc - */ - public function setKey(string $key): IdempotencyKeyInterface - { - return $this->setData(self::KEY, $key); - } - - /** - * @inheritDoc - */ - public function getRequestHash(): ?string - { - return $this->getData(self::REQUEST_HASH); - } - - /** - * @inheritDoc - */ - public function setRequestHash(string $requestHash): IdempotencyKeyInterface - { - return $this->setData(self::REQUEST_HASH, $requestHash); - } - - /** - * @inheritDoc - */ - public function getResponseStatus(): ?int - { - $value = $this->getData(self::RESPONSE_STATUS); - return $value !== null && $value !== '' ? (int) $value : null; - } - - /** - * @inheritDoc - */ - public function setResponseStatus(int $responseStatus): IdempotencyKeyInterface - { - return $this->setData(self::RESPONSE_STATUS, $responseStatus); - } - - /** - * @inheritDoc - */ - public function getResponseBody(): ?string - { - return $this->getData(self::RESPONSE_BODY); - } - - /** - * @inheritDoc - */ - public function setResponseBody(string $responseBody): IdempotencyKeyInterface - { - return $this->setData(self::RESPONSE_BODY, $responseBody); - } - - /** - * @inheritDoc - */ - public function getCreatedAt(): ?string - { - return $this->getData(self::CREATED_AT); - } - - /** - * @inheritDoc - */ - public function setCreatedAt(string $createdAt): IdempotencyKeyInterface - { - return $this->setData(self::CREATED_AT, $createdAt); - } - - /** - * @inheritDoc - */ - public function getUpdatedAt(): ?string - { - return $this->getData(self::UPDATED_AT); - } - - /** - * @inheritDoc - */ - public function setUpdatedAt(string $updatedAt): IdempotencyKeyInterface - { - return $this->setData(self::UPDATED_AT, $updatedAt); - } -} diff --git a/Model/IdempotencyKey/Repository.php b/Model/IdempotencyKey/Repository.php deleted file mode 100644 index 7a96745..0000000 --- a/Model/IdempotencyKey/Repository.php +++ /dev/null @@ -1,143 +0,0 @@ - - * @license MIT - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Model\IdempotencyKey; - -use Magebit\UniversalCommerce\Api\Data\IdempotencyKeyInterface; -use Magebit\UniversalCommerce\Api\IdempotencyKeyRepositoryInterface; -use Magento\Framework\Exception\CouldNotDeleteException; -use Magento\Framework\Exception\CouldNotSaveException; -use Magento\Framework\Exception\NoSuchEntityException; -use Psr\Log\LoggerInterface; - -/** - * Repository for Idempotency Key Model - * Handles CRUD operations for idempotency key records - */ -class Repository implements IdempotencyKeyRepositoryInterface -{ - /** - * @param ResourceModel $resourceModel - * @param ModelFactory $idempotencyKeyFactory - * @param LoggerInterface $logger - */ - public function __construct( - private readonly ResourceModel $resourceModel, - private readonly ModelFactory $idempotencyKeyFactory, - private readonly LoggerInterface $logger - ) { - } - - /** - * @inheritDoc - */ - public function save(IdempotencyKeyInterface $idempotencyKey): IdempotencyKeyInterface - { - try { - /** @var Model $idempotencyKey */ - $this->resourceModel->save($idempotencyKey); - } catch (\Exception $exception) { - $this->logger->error($exception->getMessage()); - throw new CouldNotSaveException( - __('Could not save the idempotency key record: %1', $exception->getMessage()), - $exception - ); - } - - return $idempotencyKey; - } - - /** - * @inheritDoc - */ - public function getById(int $entityId): IdempotencyKeyInterface - { - $idempotencyKey = $this->idempotencyKeyFactory->create(); - $this->resourceModel->load($idempotencyKey, $entityId); - - if (!$idempotencyKey->getEntityId()) { - throw new NoSuchEntityException( - __('Idempotency key record with ID "%1" does not exist.', $entityId) - ); - } - - return $idempotencyKey; - } - - /** - * @inheritDoc - */ - public function getByKey(string $key): IdempotencyKeyInterface - { - $idempotencyKey = $this->idempotencyKeyFactory->create(); - $this->resourceModel->load($idempotencyKey, $key, IdempotencyKeyInterface::KEY); - - if (!$idempotencyKey->getEntityId()) { - throw new NoSuchEntityException( - __('Idempotency key record with key "%1" does not exist.', $key) - ); - } - - return $idempotencyKey; - } - - /** - * @inheritDoc - */ - public function claim(string $key, string $requestHash): bool - { - return $this->resourceModel->claim($key, $requestHash); - } - - /** - * @inheritDoc - */ - public function reclaimAbandoned(string $key, string $requestHash, string $abandonedBefore): bool - { - return $this->resourceModel->reclaimAbandoned($key, $requestHash, $abandonedBefore); - } - - /** - * @inheritDoc - */ - public function deleteExpired(string $expiredBefore): int - { - return $this->resourceModel->deleteExpired($expiredBefore); - } - - /** - * @inheritDoc - */ - public function delete(IdempotencyKeyInterface $idempotencyKey): bool - { - try { - /** @var Model $idempotencyKey */ - $this->resourceModel->delete($idempotencyKey); - } catch (\Exception $exception) { - $this->logger->error($exception->getMessage()); - throw new CouldNotDeleteException( - __('Could not delete the idempotency key record: %1', $exception->getMessage()), - $exception - ); - } - - return true; - } - - /** - * @inheritDoc - */ - public function deleteById(int $entityId): bool - { - return $this->delete($this->getById($entityId)); - } -} diff --git a/Model/IdempotencyKey/ResourceModel.php b/Model/IdempotencyKey/ResourceModel.php deleted file mode 100644 index 56ef9fd..0000000 --- a/Model/IdempotencyKey/ResourceModel.php +++ /dev/null @@ -1,118 +0,0 @@ - - * @license MIT - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Model\IdempotencyKey; - -use Magebit\UniversalCommerce\Api\Data\IdempotencyKeyInterface; -use Magento\Framework\DB\Adapter\AdapterInterface; -use Magento\Framework\DB\Adapter\DuplicateException; -use Magento\Framework\DB\Sql\Expression; -use Magento\Framework\Model\ResourceModel\Db\AbstractDb; - -/** - * Idempotency Key Resource Model - */ -class ResourceModel extends AbstractDb -{ - private const TABLE_NAME = 'ucp_idempotency_keys'; - - /** - * Initialize resource model - * - * @return void - */ - protected function _construct(): void - { - $this->_init(self::TABLE_NAME, IdempotencyKeyInterface::ENTITY_ID); - } - - /** - * Insert a claim row, relying on the unique key to arbitrate concurrent callers. - * - * @param string $key - * @param string $requestHash - * @return bool True when this caller won the claim. - */ - public function claim(string $key, string $requestHash): bool - { - try { - $this->connection()->insert($this->getMainTable(), [ - IdempotencyKeyInterface::KEY => $key, - IdempotencyKeyInterface::REQUEST_HASH => $requestHash, - IdempotencyKeyInterface::RESPONSE_STATUS => null, - IdempotencyKeyInterface::RESPONSE_BODY => null, - ]); - } catch (DuplicateException $exception) { - return false; - } - - return true; - } - - /** - * Take over a claim whose owner died before storing a response. - * - * @param string $key - * @param string $requestHash - * @param string $abandonedBefore UTC datetime; claims created before it are considered abandoned. - * @return bool True when this caller took the claim over. - */ - public function reclaimAbandoned(string $key, string $requestHash, string $abandonedBefore): bool - { - $connection = $this->connection(); - - $affected = $connection->update( - $this->getMainTable(), - [ - IdempotencyKeyInterface::REQUEST_HASH => $requestHash, - IdempotencyKeyInterface::CREATED_AT => new Expression('UTC_TIMESTAMP()'), - ], - [ - $connection->quoteIdentifier(IdempotencyKeyInterface::KEY) . ' = ?' => $key, - IdempotencyKeyInterface::RESPONSE_STATUS . ' IS NULL', - IdempotencyKeyInterface::CREATED_AT . ' < ?' => $abandonedBefore, - ] - ); - - return $affected > 0; - } - - /** - * @param string $expiredBefore UTC datetime; rows created before it are removed. - * @return int Number of deleted rows. - */ - public function deleteExpired(string $expiredBefore): int - { - return $this->connection()->delete( - $this->getMainTable(), - [IdempotencyKeyInterface::CREATED_AT . ' < ?' => $expiredBefore] - ); - } - - /** - * getConnection() is typed AdapterInterface|false upstream, but a resource model - * without a connection cannot work. - * - * @return AdapterInterface - * @throws \RuntimeException - */ - private function connection(): AdapterInterface - { - $connection = $this->getConnection(); - - if ($connection === false) { - throw new \RuntimeException('No database connection available.'); - } - - return $connection; - } -} diff --git a/Model/Order/AdjustmentRecorder.php b/Model/Order/AdjustmentRecorder.php new file mode 100644 index 0000000..2235d34 --- /dev/null +++ b/Model/Order/AdjustmentRecorder.php @@ -0,0 +1,89 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Order; + +use Magebit\AgenticCore\Model\Order\Note; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentInterface; +use Magebit\UniversalCommerce\Api\OrderAdjustmentRepositoryInterface; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToAdjustments; +use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Sales\Model\Order; + +/** + * Takes down the post-order adjustments an agent asks for. The store does not act on them: an agent + * cannot refund itself, so the request is recorded and put in front of whoever handles the order. + */ +class AdjustmentRecorder +{ + /** + * @param OrderAdjustmentRepositoryInterface $repository + * @param OrderToAdjustments $adjustmentsConverter + * @param Note $orderNote + */ + public function __construct( + private readonly OrderAdjustmentRepositoryInterface $repository, + private readonly OrderToAdjustments $adjustmentsConverter, + private readonly Note $orderNote + ) { + } + + /** + * @param Order $order + * @param AdjustmentInterface[] $submitted + * @return int How many were new + * @throws CouldNotSaveException + */ + public function record(Order $order, array $submitted): int + { + $entityId = $order->getEntityId(); + + if (!is_numeric($entityId) || $submitted === []) { + return 0; + } + + $known = $this->adjustmentsConverter->knownIds($order); + $recorded = 0; + + foreach ($submitted as $adjustment) { + $id = $adjustment->getId(); + + if (in_array($id, $known, true)) { + continue; + } + + $this->repository->record((int) $entityId, $id, (string) json_encode($adjustment)); + $this->orderNote->add($order, $this->noteFor($adjustment)); + $recorded++; + } + + return $recorded; + } + + /** + * @param AdjustmentInterface $adjustment + * @return string + */ + private function noteFor(AdjustmentInterface $adjustment): string + { + $note = sprintf( + 'An agent asked for a %s over UCP (%s), status %s.', + $adjustment->getType(), + $adjustment->getId(), + $adjustment->getStatus() + ); + + $description = $adjustment->getDescription(); + + return $description === null || $description === '' ? $note : $note . ' ' . $description; + } +} diff --git a/Model/Order/SessionOrderLookup.php b/Model/Order/SessionOrderLookup.php new file mode 100644 index 0000000..d656a0e --- /dev/null +++ b/Model/Order/SessionOrderLookup.php @@ -0,0 +1,61 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Order; + +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; + +/** + * Finds the order a checkout session placed. That session is the identifier the protocol hands out, + * because it cannot be guessed, unlike the store's own order number which runs in sequence. + */ +class SessionOrderLookup +{ + /** + * @param OrderLinkRepositoryInterface $orderLinkRepository + * @param OrderRepositoryInterface $orderRepository + */ + public function __construct( + private readonly OrderLinkRepositoryInterface $orderLinkRepository, + private readonly OrderRepositoryInterface $orderRepository + ) { + } + + /** + * @param string $checkoutId + * @return Order|null Null when no session of this protocol placed an order under this identifier + */ + public function find(string $checkoutId): ?Order + { + if ($checkoutId === '') { + return null; + } + + $orderId = $this->orderLinkRepository->findOrderId(IdempotencyHandler::SCOPE, $checkoutId); + + if ($orderId === null) { + return null; + } + + try { + $order = $this->orderRepository->get($orderId); + } catch (NoSuchEntityException $exception) { + return null; + } + + return $order instanceof Order ? $order : null; + } +} diff --git a/Model/IdempotencyKey/Collection.php b/Model/OrderAdjustment/Collection.php similarity index 81% rename from Model/IdempotencyKey/Collection.php rename to Model/OrderAdjustment/Collection.php index 4d0a93d..fafb610 100644 --- a/Model/IdempotencyKey/Collection.php +++ b/Model/OrderAdjustment/Collection.php @@ -10,13 +10,10 @@ declare(strict_types=1); -namespace Magebit\UniversalCommerce\Model\IdempotencyKey; +namespace Magebit\UniversalCommerce\Model\OrderAdjustment; use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection; -/** - * Idempotency Key Collection - */ class Collection extends AbstractCollection { /** @@ -25,8 +22,6 @@ class Collection extends AbstractCollection protected $_idFieldName = 'entity_id'; /** - * Initialize collection model - * * @return void */ protected function _construct(): void diff --git a/Model/OrderAdjustment/Model.php b/Model/OrderAdjustment/Model.php new file mode 100644 index 0000000..df42b32 --- /dev/null +++ b/Model/OrderAdjustment/Model.php @@ -0,0 +1,111 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\OrderAdjustment; + +use Magebit\UniversalCommerce\Api\Data\OrderAdjustmentInterface; +use Magento\Framework\Model\AbstractModel; + +class Model extends AbstractModel implements OrderAdjustmentInterface +{ + /** + * @return void + */ + protected function _construct(): void + { + $this->_init(ResourceModel::class); + } + + /** + * @inheritDoc + */ + public function getEntityId(): ?int + { + $value = $this->getData(self::ENTITY_ID); + + return is_numeric($value) ? (int) $value : null; + } + + /** + * @inheritDoc + */ + public function getOrderId(): ?int + { + $value = $this->getData(self::ORDER_ID); + + return is_numeric($value) ? (int) $value : null; + } + + /** + * @inheritDoc + */ + public function setOrderId(int $orderId): OrderAdjustmentInterface + { + return $this->setData(self::ORDER_ID, $orderId); + } + + /** + * @inheritDoc + */ + public function getAdjustmentId(): ?string + { + $value = $this->getData(self::ADJUSTMENT_ID); + + return is_string($value) ? $value : null; + } + + /** + * @inheritDoc + */ + public function setAdjustmentId(string $adjustmentId): OrderAdjustmentInterface + { + return $this->setData(self::ADJUSTMENT_ID, $adjustmentId); + } + + /** + * @inheritDoc + */ + public function getPayload(): ?string + { + $value = $this->getData(self::PAYLOAD); + + return is_string($value) ? $value : null; + } + + /** + * @inheritDoc + */ + public function setPayload(string $payload): OrderAdjustmentInterface + { + return $this->setData(self::PAYLOAD, $payload); + } + + /** + * @inheritDoc + */ + public function getCreatedAt(): ?string + { + $value = $this->getData(self::CREATED_AT); + + return is_string($value) ? $value : null; + } + + /** + * @inheritDoc + */ + public function getUpdatedAt(): ?string + { + $value = $this->getData(self::UPDATED_AT); + + return is_string($value) ? $value : null; + } +} diff --git a/Model/OrderAdjustment/Repository.php b/Model/OrderAdjustment/Repository.php new file mode 100644 index 0000000..4f1a57b --- /dev/null +++ b/Model/OrderAdjustment/Repository.php @@ -0,0 +1,97 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\OrderAdjustment; + +use Magebit\UniversalCommerce\Api\Data\OrderAdjustmentInterface; +use Magebit\UniversalCommerce\Api\OrderAdjustmentRepositoryInterface; +use Magento\Framework\Exception\CouldNotSaveException; +use Psr\Log\LoggerInterface; + +class Repository implements OrderAdjustmentRepositoryInterface +{ + /** + * @param ResourceModel $resourceModel + * @param ModelFactory $adjustmentFactory + * @param CollectionFactory $collectionFactory + * @param LoggerInterface $logger + */ + public function __construct( + private readonly ResourceModel $resourceModel, + private readonly ModelFactory $adjustmentFactory, + private readonly CollectionFactory $collectionFactory, + private readonly LoggerInterface $logger + ) { + } + + /** + * @inheritDoc + */ + public function record(int $orderId, string $adjustmentId, string $payload): OrderAdjustmentInterface + { + $adjustment = $this->findExisting($orderId, $adjustmentId) ?? $this->adjustmentFactory->create(); + $adjustment->setOrderId($orderId); + $adjustment->setAdjustmentId($adjustmentId); + $adjustment->setPayload($payload); + + try { + /** @var Model $adjustment */ + $this->resourceModel->save($adjustment); + } catch (\Exception $exception) { + $this->logger->error($exception->getMessage()); + + throw new CouldNotSaveException( + __('Could not record the order adjustment: %1', $exception->getMessage()), + $exception + ); + } + + return $adjustment; + } + + /** + * @inheritDoc + */ + public function getByOrderId(int $orderId): array + { + $collection = $this->collectionFactory->create(); + $collection->addFieldToFilter(OrderAdjustmentInterface::ORDER_ID, ['eq' => $orderId]); + $collection->setOrder(OrderAdjustmentInterface::ENTITY_ID, 'ASC'); + + $adjustments = []; + + foreach ($collection as $adjustment) { + if ($adjustment instanceof OrderAdjustmentInterface) { + $adjustments[] = $adjustment; + } + } + + return $adjustments; + } + + /** + * @param int $orderId + * @param string $adjustmentId + * @return Model|null + */ + private function findExisting(int $orderId, string $adjustmentId): ?Model + { + $collection = $this->collectionFactory->create(); + $collection->addFieldToFilter(OrderAdjustmentInterface::ORDER_ID, ['eq' => $orderId]); + $collection->addFieldToFilter(OrderAdjustmentInterface::ADJUSTMENT_ID, ['eq' => $adjustmentId]); + $collection->setPageSize(1); + + $existing = $collection->getFirstItem(); + + return $existing instanceof Model && $existing->getEntityId() !== null ? $existing : null; + } +} diff --git a/Model/OrderAdjustment/ResourceModel.php b/Model/OrderAdjustment/ResourceModel.php new file mode 100644 index 0000000..790ae05 --- /dev/null +++ b/Model/OrderAdjustment/ResourceModel.php @@ -0,0 +1,29 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\OrderAdjustment; + +use Magebit\UniversalCommerce\Api\Data\OrderAdjustmentInterface; +use Magento\Framework\Model\ResourceModel\Db\AbstractDb; + +class ResourceModel extends AbstractDb +{ + private const TABLE_NAME = 'ucp_order_adjustment'; + + /** + * @return void + */ + protected function _construct(): void + { + $this->_init(self::TABLE_NAME, OrderAdjustmentInterface::ENTITY_ID); + } +} diff --git a/Model/Protocol/UndeclaredExtensions.php b/Model/Protocol/UndeclaredExtensions.php new file mode 100644 index 0000000..9e95ed3 --- /dev/null +++ b/Model/Protocol/UndeclaredExtensions.php @@ -0,0 +1,111 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Protocol; + +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +use Magebit\UniversalCommerce\Model\Discovery\ServiceRegistry; + +/** + * Says so when a request carries an extension this store does not implement. The specification leaves + * request objects open so extensions can ride along, which means an unimplemented one would otherwise + * be dropped in silence — and an agent that sent a signed mandate would go on believing it was checked. + */ +class UndeclaredExtensions +{ + public const CODE_NOT_ACCEPTED = 'not_accepted'; + + private const SERVICE_NAME = 'dev.ucp.shopping'; + + /** + * @param ServiceRegistry $serviceRegistry + * @param MessageInterfaceFactory $messageFactory + * @param array $extensions Request field to the capability that defines it + */ + public function __construct( + private readonly ServiceRegistry $serviceRegistry, + private readonly MessageInterfaceFactory $messageFactory, + private readonly array $extensions = [] + ) { + } + + /** + * @param CheckoutResponseInterface $response + * @param array $body Request body as it arrived + * @return void + */ + public function annotate(CheckoutResponseInterface $response, array $body): void + { + $warnings = $this->report($body); + + if ($warnings === []) { + return; + } + + $response->setMessages(array_merge($response->getMessages() ?? [], $warnings)); + } + + /** + * @param array $body Request body as it arrived + * @return MessageInterface[] + */ + public function report(array $body): array + { + $declared = $this->declaredCapabilities(); + $warnings = []; + + foreach ($this->extensions as $field => $capability) { + if (!array_key_exists($field, $body) || in_array($capability, $declared, true)) { + continue; + } + + $warnings[] = $this->warning($field, $capability); + } + + return $warnings; + } + + /** + * @return string[] + */ + private function declaredCapabilities(): array + { + $service = $this->serviceRegistry->getService(self::SERVICE_NAME); + + return $service === null ? [] : array_keys($service->getCapabilities()); + } + + /** + * @param string $field + * @param string $capability + * @return MessageInterface + */ + private function warning(string $field, string $capability): MessageInterface + { + /** @var MessageInterface $warning */ + $warning = $this->messageFactory->create(['data' => [ + MessageInterface::KEY_TYPE => 'warning', + MessageInterface::KEY_CODE => self::CODE_NOT_ACCEPTED, + MessageInterface::KEY_PATH => '$.' . $field, + MessageInterface::KEY_CONTENT => sprintf( + 'This store does not implement %s, so the "%s" data in this request was not read or ' + . 'verified. It is not declared in discovery.', + $capability, + $field + ), + ]]); + + return $warning; + } +} diff --git a/Model/Protocol/VersionNegotiator.php b/Model/Protocol/VersionNegotiator.php new file mode 100644 index 0000000..7fb1f52 --- /dev/null +++ b/Model/Protocol/VersionNegotiator.php @@ -0,0 +1,67 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Protocol; + +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; +use Magebit\UniversalCommerce\Exception\UcpException; + +/** + * The store speaks one version of the protocol. An agent that asks for another is told so, rather than + * being served payloads in a shape it is not expecting. + */ +class VersionNegotiator +{ + public const CODE_UNSUPPORTED_VERSION = 'unsupported_version'; + + /** + * @param string|null $agentHeader Value of the request's UCP-Agent header + * @return void + * @throws UcpException When the agent asked for a version this store does not speak + */ + public function assertSupported(?string $agentHeader): void + { + $requested = $this->requested($agentHeader); + + if ($requested === null || $requested === UniversalCommerceProtocolInterface::SPEC_VERSION) { + return; + } + + throw new UcpException( + __( + 'This store speaks UCP %1 and cannot serve %2.', + UniversalCommerceProtocolInterface::SPEC_VERSION, + $requested + ), + self::CODE_UNSUPPORTED_VERSION, + 422 + ); + } + + /** + * @param string|null $agentHeader Value of the request's UCP-Agent header + * @return string|null The version it names, or null when it names none + */ + public function requested(?string $agentHeader): ?string + { + if ($agentHeader === null || $agentHeader === '') { + return null; + } + + // The parameter is quoted in every example, but an unquoted token is still a valid one. + if (preg_match('/version=\s*"?([^";\s]+)"?/', $agentHeader, $matches) !== 1) { + return null; + } + + return $matches[1]; + } +} diff --git a/Model/RequestClassBuilder.php b/Model/RequestClassBuilder.php deleted file mode 100644 index 85b0aca..0000000 --- a/Model/RequestClassBuilder.php +++ /dev/null @@ -1,164 +0,0 @@ - - * @copyright Copyright (c) Magebit, Ltd. (https://magebit.com) - * @license https://magebit.com/code-license - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Model; - -use Magento\Framework\Api\ObjectFactory; -use Magento\Framework\Api\SimpleDataObjectConverter; -use Magento\Framework\Reflection\MethodsMap; -use Magento\Framework\Reflection\TypeProcessor; - -/** - * Service class for populating DataObject-based request classes from array data. - * Simplified version of DataObjectHelper without ExtensibleDataInterface handling. - */ -class RequestClassBuilder -{ - /** - * @var array> - */ - private array $settersCache = []; - - /** - * @param ObjectFactory $objectFactory - * @param TypeProcessor $typeProcessor - * @param MethodsMap $methodsMapProcessor - */ - public function __construct( - private readonly ObjectFactory $objectFactory, - private readonly TypeProcessor $typeProcessor, - private readonly MethodsMap $methodsMapProcessor - ) { - } - - /** - * Populate data object using data in array format. - * - * @param object $dataObject - * @param array $data - * @param string $interfaceName - * @return $this - */ - public function populateWithArray(object $dataObject, array $data, string $interfaceName): self - { - $this->_setDataValues($dataObject, $data, $interfaceName); - return $this; - } - - /** - * Update Data Object with the data from array - * - * @param object $dataObject - * @param array $data - * @param string $interfaceName - * @return $this - */ - protected function _setDataValues(object $dataObject, array $data, string $interfaceName): self - { - if (empty($data)) { - return $this; - } - - $setMethods = $this->getSetters($dataObject); - - foreach (array_intersect_key($data, $setMethods) as $key => $value) { - $methodName = SimpleDataObjectConverter::snakeCaseToUpperCamelCase($key); - - if (!is_array($value)) { - if (method_exists($dataObject, 'set' . $methodName)) { - $dataObject->{'set' . $methodName}($value); - } else { - $dataObject->{'setIs' . $methodName}($value); - } - } else { - $getterMethodName = 'get' . $methodName; - $this->setComplexValue($dataObject, $getterMethodName, 'set' . $methodName, $value, $interfaceName); - } - unset($data[$key]); - } - - return $this; - } - - /** - * Set complex (like object) value using $methodName based on return type of $getterMethodName - * - * @param object $dataObject - * @param string $getterMethodName - * @param string $methodName - * @param array $value - * @param string $interfaceName - * @return $this - */ - protected function setComplexValue( - object $dataObject, - string $getterMethodName, - string $methodName, - array $value, - string $interfaceName - ): self { - if ($interfaceName === '') { - $interfaceName = get_class($dataObject); - } - - $returnType = $this->methodsMapProcessor->getMethodReturnType($interfaceName, $getterMethodName); - - if ($this->typeProcessor->isTypeSimple($returnType)) { - $dataObject->$methodName($value); - return $this; - } - - if ($this->typeProcessor->isArrayType($returnType)) { - $type = $this->typeProcessor->getArrayItemType($returnType); - $objects = []; - foreach ($value as $arrayElementData) { - $object = $this->objectFactory->create($type, []); - $this->populateWithArray($object, $arrayElementData, $type); - $objects[] = $object; - } - $dataObject->$methodName($objects); - return $this; - } - - // For object types, always create empty first, then populate - $object = $this->objectFactory->create($returnType, []); - $this->populateWithArray($object, $value, $returnType); - $dataObject->$methodName($object); - - return $this; - } - - /** - * Get list of setters for object - * - * @param object $dataObject - * @return array - */ - private function getSetters(object $dataObject): array - { - $class = get_class($dataObject); - if (!isset($this->settersCache[$class])) { - $dataObjectMethods = get_class_methods($class); - // use regexp to manipulate with method list as it use jit starting with PHP 7.3 - $methodsString = implode(',', $dataObjectMethods); - $processedString = preg_replace( - ['/(^|,)(?!set)[^,]*/S', '/([A-Z])/S', '/(^|,)set_/iS', '/(^|,)is_([^,]+)/is'], - ['', '_$1', '$1', '$1$2,is_$2'], - $methodsString - ); - $lowercaseString = $processedString !== null ? strtolower($processedString) : ''; - $setters = array_filter( - explode(',', $lowercaseString) - ); - $this->settersCache[$class] = array_flip($setters); - } - return $this->settersCache[$class]; - } -} diff --git a/Model/Seed/ConformanceFixtures.php b/Model/Seed/ConformanceFixtures.php new file mode 100644 index 0000000..54ef3c2 --- /dev/null +++ b/Model/Seed/ConformanceFixtures.php @@ -0,0 +1,258 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Seed; + +use Magento\Catalog\Api\ProductRepositoryInterface; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Type; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ProductFactory; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\SalesRule\Model\CouponFactory; +use Magento\SalesRule\Model\ResourceModel\Rule as RuleResource; +use Magento\SalesRule\Model\Rule; +use Magento\SalesRule\Model\RuleFactory; +use Magento\Store\Model\StoreManagerInterface; + +/** + * The fixed catalogue and coupon set the UCP conformance run expects. Re-running updates in place, so + * the command is safe to repeat. + */ +class ConformanceFixtures +{ + public const SKU_SIMPLE = 'ucp-conformance-simple'; + public const SKU_SECOND = 'ucp-conformance-second'; + public const SKU_OUT_OF_STOCK = 'ucp-conformance-oos'; + + public const COUPON_PERCENT_10 = '10OFF'; + public const COUPON_PERCENT_20 = 'WELCOME20'; + public const COUPON_FIXED_500 = 'FIXED500'; + + /** + * Magento's out-of-the-box "Default" attribute set. + */ + private const DEFAULT_ATTRIBUTE_SET_ID = 4; + + /** + * Well above anything the conformance run asks for, so stock is always the binding limit. + */ + private const MAX_SALE_QTY = 1000000; + + /** + * Fixed prices, so the conformance run can assert exact totals. + * + * @var array + */ + private const PRODUCTS = [ + [ + 'sku' => self::SKU_SIMPLE, + 'name' => 'UCP Conformance Simple Product', + 'price' => 20.00, + 'in_stock' => true, + 'qty' => 1000, + ], + [ + 'sku' => self::SKU_SECOND, + 'name' => 'UCP Conformance Second Product', + 'price' => 35.50, + 'in_stock' => true, + 'qty' => 1000, + ], + [ + 'sku' => self::SKU_OUT_OF_STOCK, + 'name' => 'UCP Conformance Out Of Stock Product', + 'price' => 15.00, + 'in_stock' => false, + 'qty' => 0, + ], + ]; + + /** + * `FIXED500` is 500 minor units, which Magento expresses as a major-unit amount. + * + * @var array + */ + private const RULES = [ + [ + 'code' => self::COUPON_PERCENT_10, + 'name' => 'UCP Conformance 10% Off', + 'action' => Rule::BY_PERCENT_ACTION, + 'amount' => 10.0, + ], + [ + 'code' => self::COUPON_PERCENT_20, + 'name' => 'UCP Conformance 20% Off', + 'action' => Rule::BY_PERCENT_ACTION, + 'amount' => 20.0, + ], + [ + 'code' => self::COUPON_FIXED_500, + 'name' => 'UCP Conformance 500 Minor Units Off', + 'action' => Rule::CART_FIXED_ACTION, + 'amount' => 5.00, + ], + ]; + + /** + * @param ProductRepositoryInterface $productRepository + * @param ProductFactory $productFactory + * @param RuleFactory $ruleFactory + * @param RuleResource $ruleResource + * @param CouponFactory $couponFactory + * @param StoreManagerInterface $storeManager + */ + public function __construct( + private readonly ProductRepositoryInterface $productRepository, + private readonly ProductFactory $productFactory, + private readonly RuleFactory $ruleFactory, + private readonly RuleResource $ruleResource, + private readonly CouponFactory $couponFactory, + private readonly StoreManagerInterface $storeManager, + ) { + } + + /** + * @return array + */ + public static function products(): array + { + return self::PRODUCTS; + } + + /** + * @return array + */ + public static function rules(): array + { + return self::RULES; + } + + /** + * @return string[] SKUs that were created or updated + */ + public function applyProducts(): array + { + $applied = []; + + foreach (self::PRODUCTS as $definition) { + $this->applyProduct($definition); + $applied[] = $definition['sku']; + } + + return $applied; + } + + /** + * @return string[] Coupon codes that were created or updated + */ + public function applyRules(): array + { + $applied = []; + + foreach (self::RULES as $definition) { + $this->applyRule($definition); + $applied[] = $definition['code']; + } + + return $applied; + } + + /** + * @param array{sku: string, name: string, price: float, in_stock: bool, qty: int} $definition + * @return void + */ + private function applyProduct(array $definition): void + { + try { + /** @var Product $product */ + $product = $this->productRepository->get($definition['sku'], true, null, true); + } catch (NoSuchEntityException $exception) { + $product = $this->productFactory->create(); + $product->setSku($definition['sku']); + $product->setTypeId(Type::TYPE_SIMPLE); + $product->setAttributeSetId(self::DEFAULT_ATTRIBUTE_SET_ID); + } + + $product->setName($definition['name']); + $product->setPrice($definition['price']); + $product->setStatus(Status::STATUS_ENABLED); + $product->setVisibility(Visibility::VISIBILITY_BOTH); + $product->setWebsiteIds(array_keys($this->storeManager->getWebsites())); + $product->setStockData([ + 'use_config_manage_stock' => 1, + 'is_in_stock' => $definition['in_stock'] ? 1 : 0, + 'qty' => $definition['qty'], + // The conformance run asks for far more units than the store has, to see stock refuse it. + // Magento's default cart cap of 10,000 would answer first and hide that, so it is lifted. + 'use_config_max_sale_qty' => 0, + 'max_sale_qty' => self::MAX_SALE_QTY, + ]); + + $this->productRepository->save($product); + } + + /** + * @param array{code: string, name: string, action: string, amount: float} $definition + * @return void + */ + private function applyRule(array $definition): void + { + $rule = $this->findRuleByCoupon($definition['code']) ?? $this->ruleFactory->create(); + + $rule->setName($definition['name']); + $rule->setDescription('Created by magebit:seed:ucp-conformance.'); + $rule->setIsActive(1); + $rule->setCouponType(Rule::COUPON_TYPE_SPECIFIC); + $rule->setCouponCode($definition['code']); + $rule->setSimpleAction($definition['action']); + $rule->setDiscountAmount($definition['amount']); + $rule->setDiscountQty(0); + $rule->setStopRulesProcessing(false); + $rule->setSortOrder(0); + $rule->setUsesPerCustomer(0); + $rule->setCustomerGroupIds($this->getAllCustomerGroupIds()); + $rule->setWebsiteIds(array_keys($this->storeManager->getWebsites())); + + $this->ruleResource->save($rule); + } + + /** + * @param string $code + * @return Rule|null + */ + private function findRuleByCoupon(string $code): ?Rule + { + $coupon = $this->couponFactory->create()->loadByCode($code); + $ruleId = $coupon->getRuleId(); + + if (!$ruleId) { + return null; + } + + $rule = $this->ruleFactory->create(); + $this->ruleResource->load($rule, (int)$ruleId); + + return $rule->getId() ? $rule : null; + } + + /** + * Every group, so the conformance run's guest carts qualify. + * + * @return int[] + */ + private function getAllCustomerGroupIds(): array + { + return [0, 1, 2, 3]; + } +} diff --git a/Model/Service/Shopping/AgentProfileParser.php b/Model/Service/Shopping/AgentProfileParser.php index 0dff191..6d5ec6e 100644 --- a/Model/Service/Shopping/AgentProfileParser.php +++ b/Model/Service/Shopping/AgentProfileParser.php @@ -12,8 +12,8 @@ namespace Magebit\UniversalCommerce\Model\Service\Shopping; -use Magebit\UcpSpec\Api\Shopping\OrderPlatformSchemaInterface; -use Magebit\UcpSpec\Api\Shopping\OrderPlatformSchemaInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\OrderResponsePlatformSchemaInterface; +use Magebit\UcpSpec\Api\Shopping\OrderResponsePlatformSchemaInterfaceFactory; use Magento\Framework\App\CacheInterface; use Magento\Framework\HTTP\Client\CurlFactory; use Psr\Log\LoggerInterface; @@ -24,6 +24,11 @@ */ class AgentProfileParser { + /** + * Capability whose platform-side config carries the URL order events are sent to. + */ + private const ORDER_CAPABILITY = 'dev.ucp.shopping.order'; + private const CACHE_PREFIX = 'ucp_agent_profile_'; private const CACHE_LIFETIME = 3600; // 1 hour private const CACHE_TAG = 'ucp_agent_profile'; @@ -33,14 +38,14 @@ class AgentProfileParser private const MAX_DATA_URI_BYTES = 262144; /** - * @param OrderPlatformSchemaInterfaceFactory $platformSchemaFactory + * @param OrderResponsePlatformSchemaInterfaceFactory $platformSchemaFactory * @param CurlFactory $curlFactory * @param CacheInterface $cache * @param LoggerInterface $logger * @param ProfileUrlValidator $urlValidator */ public function __construct( - private readonly OrderPlatformSchemaInterfaceFactory $platformSchemaFactory, + private readonly OrderResponsePlatformSchemaInterfaceFactory $platformSchemaFactory, private readonly CurlFactory $curlFactory, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, @@ -52,39 +57,48 @@ public function __construct( * Parse UCP agent profile from header * * @param string|null $ucpAgentHeader - * @return OrderPlatformSchemaInterface + * @return OrderResponsePlatformSchemaInterface */ - public function parse(?string $ucpAgentHeader = null): OrderPlatformSchemaInterface + public function parse(?string $ucpAgentHeader = null): OrderResponsePlatformSchemaInterface { $platformConfig = $this->platformSchemaFactory->create(); + $webhookUrl = $this->parseWebhookUrl($ucpAgentHeader); + + // Left unset rather than blank when the profile declares none: the generated field is + // required-typed, so writing null would make every later read raise. + return $webhookUrl === null ? $platformConfig : $platformConfig->setWebhookUrl($webhookUrl); + } + /** + * The agent's own order-event URL, or null when it declares none. Callers want the field far more + * often than the schema around it, and the schema's getter raises on an absent value. + * + * @param string|null $ucpAgentHeader + * @return string|null + */ + public function parseWebhookUrl(?string $ucpAgentHeader = null): ?string + { if (!$ucpAgentHeader) { - return $platformConfig; + return null; } $profileUri = $this->extractProfileUri($ucpAgentHeader); + if (!$profileUri) { - return $platformConfig; + return null; } try { $profileData = $this->fetchProfileData($profileUri); - if (!$profileData) { - return $platformConfig; - } - - $webhookUrl = $this->extractWebhookUrl($profileData); - if (!$webhookUrl) { - return $platformConfig; - } - return $platformConfig->setWebhookUrl($webhookUrl); + return $profileData ? $this->extractWebhookUrl($profileData) : null; } catch (\Exception $e) { $this->logger->warning('Failed to fetch or parse agent profile', [ 'exception' => $e->getMessage(), 'uri' => $profileUri ]); - return $platformConfig; + + return null; } } @@ -252,22 +266,49 @@ private function pinResolution(string $url, array $addresses): array */ private function extractWebhookUrl(array $profileData): ?string { - if (!isset($profileData['ucp']['capabilities']) || !is_array($profileData['ucp']['capabilities'])) { + $ucp = $profileData['ucp'] ?? null; + $capabilities = is_array($ucp) ? ($ucp['capabilities'] ?? null) : null; + $entries = is_array($capabilities) ? ($capabilities[self::ORDER_CAPABILITY] ?? null) : null; + + if (!is_array($entries)) { return null; } - foreach ($profileData['ucp']['capabilities'] as $capability) { - if (!is_array($capability)) { - continue; - } + // The registry is keyed by reverse-domain name and each value is a list of entries, so the name + // is the key and no entry carries one. A platform may declare several entries for one capability. + foreach ($entries as $entry) { + if (is_array($entry) && isset($entry['config']['webhook_url'])) { + $url = $entry['config']['webhook_url']; - if (($capability['name'] ?? null) === 'dev.ucp.shopping.order' && - isset($capability['config']['webhook_url']) - ) { - return $capability['config']['webhook_url']; + if (is_string($url) && $url !== '' && $this->isCallableWebhookUrl($url)) { + return $url; + } } } return null; } + + /** + * The store posts to this address later, so it needs the same guard as the profile URL or it can + * point back inside our own network. A bad one is dropped, not fatal: the agent can still shop. + * + * @param string $url + * @return bool + */ + private function isCallableWebhookUrl(string $url): bool + { + try { + $this->urlValidator->assertFetchable($url); + + return true; + } catch (\Throwable $e) { + $this->logger->warning('Dropped an agent webhook URL the store is not allowed to call', [ + 'exception' => $e->getMessage(), + 'url' => $url + ]); + + return false; + } + } } diff --git a/Model/Service/Shopping/Capability/BuyerConsentCapability.php b/Model/Service/Shopping/Capability/BuyerConsentCapability.php new file mode 100644 index 0000000..b7520a3 --- /dev/null +++ b/Model/Service/Shopping/Capability/BuyerConsentCapability.php @@ -0,0 +1,66 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Capability; + +use Magebit\UcpSpec\Data\CapabilityBusinessSchema; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; + +class BuyerConsentCapability extends CapabilityBusinessSchema +{ + /** + * @return string + */ + public function getVersion(): string + { + return UniversalCommerceProtocolInterface::SPEC_VERSION; + } + + /** + * @return string + */ + public function getSpec(): string + { + return 'https://ucp.dev/specs/shopping/buyer-consent'; + } + + /** + * @return string + */ + public function getSchema(): string + { + return 'https://ucp.dev/schemas/shopping/buyer_consent.json'; + } + + /** + * @return string|null + */ + public function getId(): string|null + { + return null; + } + + /** + * @return array|null + */ + public function getConfig(): array|null + { + return null; + } + + /** + * @return string|null + */ + public function getExtends(): string|null + { + return 'dev.ucp.shopping.checkout'; + } +} diff --git a/Model/Service/Shopping/Capability/CartCapability.php b/Model/Service/Shopping/Capability/CartCapability.php new file mode 100644 index 0000000..d9d0249 --- /dev/null +++ b/Model/Service/Shopping/Capability/CartCapability.php @@ -0,0 +1,66 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Capability; + +use Magebit\UcpSpec\Data\CapabilityBusinessSchema; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; + +class CartCapability extends CapabilityBusinessSchema +{ + /** + * @return string + */ + public function getVersion(): string + { + return UniversalCommerceProtocolInterface::SPEC_VERSION; + } + + /** + * @return string + */ + public function getSpec(): string + { + return 'https://ucp.dev/specs/shopping/cart'; + } + + /** + * @return string + */ + public function getSchema(): string + { + return 'https://ucp.dev/schemas/shopping/cart.json'; + } + + /** + * @return string|null + */ + public function getId(): string|null + { + return null; + } + + /** + * @return array|null + */ + public function getConfig(): array|null + { + return null; + } + + /** + * @return string|null + */ + public function getExtends(): string|null + { + return null; + } +} diff --git a/Model/Service/Shopping/Capability/CatalogLookupCapability.php b/Model/Service/Shopping/Capability/CatalogLookupCapability.php new file mode 100644 index 0000000..bfa1b36 --- /dev/null +++ b/Model/Service/Shopping/Capability/CatalogLookupCapability.php @@ -0,0 +1,66 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Capability; + +use Magebit\UcpSpec\Data\CapabilityBusinessSchema; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; + +class CatalogLookupCapability extends CapabilityBusinessSchema +{ + /** + * @return string + */ + public function getVersion(): string + { + return UniversalCommerceProtocolInterface::SPEC_VERSION; + } + + /** + * @return string + */ + public function getSpec(): string + { + return 'https://ucp.dev/specs/shopping/catalog-lookup'; + } + + /** + * @return string + */ + public function getSchema(): string + { + return 'https://ucp.dev/schemas/shopping/catalog_lookup.json'; + } + + /** + * @return string|null + */ + public function getId(): string|null + { + return null; + } + + /** + * @return array|null + */ + public function getConfig(): array|null + { + return null; + } + + /** + * @return string|null + */ + public function getExtends(): string|null + { + return null; + } +} diff --git a/Model/Service/Shopping/Capability/CatalogSearchCapability.php b/Model/Service/Shopping/Capability/CatalogSearchCapability.php new file mode 100644 index 0000000..cbce49d --- /dev/null +++ b/Model/Service/Shopping/Capability/CatalogSearchCapability.php @@ -0,0 +1,66 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Capability; + +use Magebit\UcpSpec\Data\CapabilityBusinessSchema; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; + +class CatalogSearchCapability extends CapabilityBusinessSchema +{ + /** + * @return string + */ + public function getVersion(): string + { + return UniversalCommerceProtocolInterface::SPEC_VERSION; + } + + /** + * @return string + */ + public function getSpec(): string + { + return 'https://ucp.dev/specs/shopping/catalog-search'; + } + + /** + * @return string + */ + public function getSchema(): string + { + return 'https://ucp.dev/schemas/shopping/catalog_search.json'; + } + + /** + * @return string|null + */ + public function getId(): string|null + { + return null; + } + + /** + * @return array|null + */ + public function getConfig(): array|null + { + return null; + } + + /** + * @return string|null + */ + public function getExtends(): string|null + { + return null; + } +} diff --git a/Model/Service/Shopping/Capability/FulfillmentCapability.php b/Model/Service/Shopping/Capability/FulfillmentCapability.php new file mode 100644 index 0000000..c1c4956 --- /dev/null +++ b/Model/Service/Shopping/Capability/FulfillmentCapability.php @@ -0,0 +1,66 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Capability; + +use Magebit\UcpSpec\Data\CapabilityBusinessSchema; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; + +class FulfillmentCapability extends CapabilityBusinessSchema +{ + /** + * @return string + */ + public function getVersion(): string + { + return UniversalCommerceProtocolInterface::SPEC_VERSION; + } + + /** + * @return string + */ + public function getSpec(): string + { + return 'https://ucp.dev/specs/shopping/fulfillment'; + } + + /** + * @return string + */ + public function getSchema(): string + { + return 'https://ucp.dev/schemas/shopping/fulfillment.json'; + } + + /** + * @return string|null + */ + public function getId(): string|null + { + return null; + } + + /** + * @return array|null + */ + public function getConfig(): array|null + { + return null; + } + + /** + * @return string|null + */ + public function getExtends(): string|null + { + return 'dev.ucp.shopping.checkout'; + } +} diff --git a/Model/Service/Shopping/Capability/OrderCapability.php b/Model/Service/Shopping/Capability/OrderCapability.php new file mode 100644 index 0000000..bb36ddf --- /dev/null +++ b/Model/Service/Shopping/Capability/OrderCapability.php @@ -0,0 +1,70 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Capability; + +use Magebit\UcpSpec\Data\CapabilityBusinessSchema; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; + +/** + * The order capability: reading an order the business already placed. It stands on its own rather than + * extending checkout, since an order outlives the session that produced it. + */ +class OrderCapability extends CapabilityBusinessSchema +{ + /** + * @return string + */ + public function getVersion(): string + { + return UniversalCommerceProtocolInterface::SPEC_VERSION; + } + + /** + * @return string + */ + public function getSpec(): string + { + return 'https://ucp.dev/specs/shopping/order'; + } + + /** + * @return string + */ + public function getSchema(): string + { + return 'https://ucp.dev/schemas/shopping/order.json'; + } + + /** + * @return string|null + */ + public function getId(): string|null + { + return null; + } + + /** + * @return array|null + */ + public function getConfig(): array|null + { + return null; + } + + /** + * @return string|null + */ + public function getExtends(): string|null + { + return null; + } +} diff --git a/Model/Service/Shopping/CartHandler.php b/Model/Service/Shopping/CartHandler.php new file mode 100644 index 0000000..4ec01a2 --- /dev/null +++ b/Model/Service/Shopping/CartHandler.php @@ -0,0 +1,124 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping; + +use Magebit\UniversalCommerce\Api\Service\Shopping\CartHandlerInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutCreateRequestInterface; +use Magebit\UcpSpec\Api\Shopping\CartResponseInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutUpdateRequestInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\RestHandlerInterface; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToCartResponse; +use Magento\Quote\Api\Data\CartInterface; + +/** + * A cart is the same quote a checkout is held on, minus the payment surface and the status lifecycle. + */ +class CartHandler implements CartHandlerInterface +{ + /** + * @param RestHandlerInterface $restHandler + * @param QuoteToCartResponse $cartConverter + */ + public function __construct( + private readonly RestHandlerInterface $restHandler, + private readonly QuoteToCartResponse $cartConverter + ) { + } + + /** + * @inheritDoc + */ + public function createCart(CheckoutCreateRequestInterface $request): CartResponseInterface + { + $created = $this->restHandler->createCheckout($request); + + return $this->convert((string) $created->getId()); + } + + /** + * @inheritDoc + */ + public function getCart(string $cartId): CartResponseInterface + { + return $this->convert($cartId, true); + } + + /** + * @inheritDoc + */ + public function updateCart(string $cartId, CheckoutUpdateRequestInterface $request): CartResponseInterface + { + $this->assertExists($cartId); + $this->restHandler->updateCheckout($cartId, $request); + + return $this->convert($cartId); + } + + /** + * The spec has cancel return the state before deletion, so the response is built from the cart as it + * stood and only later reads report it gone. + * + * @inheritDoc + */ + public function cancelCart(string $cartId): CartResponseInterface + { + $this->assertExists($cartId); + $this->restHandler->cancelCheckout($cartId); + + return $this->convert($cartId); + } + + /** + * @param string $cartId + * @param bool $mustBeLive Reads report a canceled cart as gone; the operation that canceled it does not + * @return CartResponseInterface + * @throws UcpException + */ + private function convert(string $cartId, bool $mustBeLive = false): CartResponseInterface + { + $quote = $this->restHandler->getCartByMaskedId($cartId); + + if ($mustBeLive) { + $this->assertLive($quote, $cartId); + } + + return $this->cartConverter->convert($quote, $cartId); + } + + /** + * @param string $cartId + * @return void + * @throws UcpException + */ + private function assertExists(string $cartId): void + { + $this->assertLive($this->restHandler->getCartByMaskedId($cartId), $cartId); + } + + /** + * A cart's status is binary: it exists, or it is not found. A canceled cart is gone, so it is + * reported as missing rather than as a cart in a canceled state. + * + * @param CartInterface $quote + * @param string $cartId + * @return void + * @throws UcpException + */ + private function assertLive(CartInterface $quote, string $cartId): void + { + if (!$quote->getIsActive()) { + throw new UcpException(__('Cart not found: %1.', $cartId), 'not_found', 404); + } + } +} diff --git a/Model/Service/Shopping/CatalogHandler.php b/Model/Service/Shopping/CatalogHandler.php new file mode 100644 index 0000000..0cdf711 --- /dev/null +++ b/Model/Service/Shopping/CatalogHandler.php @@ -0,0 +1,629 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\CatalogLookupGetProductResponseInterface; +use Magebit\UcpSpec\Api\Shopping\CatalogLookupDetailProductInterface; +use Magebit\UcpSpec\Api\Shopping\CatalogLookupDetailProductInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\CatalogLookupGetProductResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\CatalogLookupLookupResponseInterface; +use Magebit\UcpSpec\Api\Shopping\CatalogLookupLookupResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\CatalogSearchSearchResponseInterface; +use Magebit\UcpSpec\Api\Shopping\CatalogSearchSearchResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\PaginationResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\PaginationResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\InputCorrelationInterface; +use Magebit\UcpSpec\Api\Shopping\Types\InputCorrelationInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\ProductInterface; +use Magebit\UcpSpec\Api\UcpResponseCatalogSchemaInterface; +use Magebit\UcpSpec\Runtime\SpecObject; +use Magebit\UcpSpec\Api\UcpResponseCatalogSchemaInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\CatalogHandlerInterface; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\Discovery\ServiceRegistry; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\ProductToUcpProduct; +use Magento\Catalog\Api\Data\ProductInterface as CatalogProductInterface; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Catalog\Model\Product as MagentoProduct; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\CatalogInventory\Model\ResourceModel\Stock\Status as StockStatus; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable as ConfigurableResource; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\Framework\DB\Sql\Expression; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; + +/** + * Serves catalog search and lookup. Both are read-only: nothing here touches a quote. + */ +class CatalogHandler implements CatalogHandlerInterface +{ + /** + * A page size an agent cannot exceed, so a missing or huge limit cannot walk the whole catalogue. + */ + public const MAX_LIMIT = 50; + public const DEFAULT_LIMIT = 20; + + /** + * The cursor is the zero-based offset of the next page, encoded so it stays opaque to the agent. + */ + private const CURSOR_PREFIX = 'offset:'; + + /** + * Only reachable if the store is not a concrete Store, which the interface allows but a real request + * never produces. + */ + private const FALLBACK_CURRENCY = 'USD'; + + /** + * Tells Magento the stock filter is already handled, so it does not silently drop out-of-stock rows. + */ + private const STOCK_FILTER_FLAG = 'has_stock_status_filter'; + + /** + * Lookup correlation: how a requested identifier resolved to a variant. + */ + private const INPUTS_KEY = 'inputs'; + private const MATCH_EXACT = 'exact'; + private const MATCH_FEATURED = 'featured'; + + /** + * @param CollectionFactory $collectionFactory + * @param ProductToUcpProduct $productConverter + * @param CatalogSearchSearchResponseInterfaceFactory $searchResponseFactory + * @param CatalogLookupLookupResponseInterfaceFactory $lookupResponseFactory + * @param CatalogLookupGetProductResponseInterfaceFactory $productResponseFactory + * @param CatalogLookupDetailProductInterfaceFactory $detailProductFactory + * @param PaginationResponseInterfaceFactory $paginationFactory + * @param UcpResponseCatalogSchemaInterfaceFactory $ucpCatalogFactory + * @param ServiceRegistry $serviceRegistry + * @param StoreManagerInterface $storeManager + * @param ImageHelper $imageHelper + * @param Visibility $visibility + * @param InputCorrelationInterfaceFactory $correlationFactory + * @param ConfigurableResource $configurableResource + * @param StockStatus $stockStatus + * @param MetadataPool $metadataPool + */ + public function __construct( + private readonly CollectionFactory $collectionFactory, + private readonly ProductToUcpProduct $productConverter, + private readonly CatalogSearchSearchResponseInterfaceFactory $searchResponseFactory, + private readonly CatalogLookupLookupResponseInterfaceFactory $lookupResponseFactory, + private readonly CatalogLookupGetProductResponseInterfaceFactory $productResponseFactory, + private readonly CatalogLookupDetailProductInterfaceFactory $detailProductFactory, + private readonly PaginationResponseInterfaceFactory $paginationFactory, + private readonly UcpResponseCatalogSchemaInterfaceFactory $ucpCatalogFactory, + private readonly ServiceRegistry $serviceRegistry, + private readonly StoreManagerInterface $storeManager, + private readonly ImageHelper $imageHelper, + private readonly Visibility $visibility, + private readonly InputCorrelationInterfaceFactory $correlationFactory, + private readonly ConfigurableResource $configurableResource, + private readonly StockStatus $stockStatus, + private readonly MetadataPool $metadataPool + ) { + } + + /** + * @inheritDoc + */ + public function search( + ?string $query, + ?int $limit = null, + ?string $cursor = null + ): CatalogSearchSearchResponseInterface { + $pageSize = $this->pageSize($limit); + $offset = $this->offsetFrom($cursor); + + $collection = $this->visibleProducts(); + + if ($query !== null && trim($query) !== '') { + // Name and SKU rather than the fulltext index: the index needs reindexing to be current, and a + // stale index would answer a live catalogue query with yesterday's products. + $collection->addAttributeToFilter([ + ['attribute' => 'name', 'like' => '%' . $query . '%'], + ['attribute' => 'sku', 'like' => '%' . $query . '%'], + ]); + } + + // Counted from distinct ids rather than getSize(): with the OR'd EAV filter above, the count + // select over-reports, and an agent would page against a total that does not exist. + $total = $this->totalOf($collection); + + // Ordered and limited in the query. Reading every id and cutting the page in PHP meant one + // full scan of the catalogue per request, and the cursor offset can be any number. + $ids = $this->pageIds($collection, $pageSize, $offset); + + $found = []; + + if ($ids !== []) { + $collection->addIdFilter($ids); + $collection->getSelect()->order('e.entity_id ' . Select::SQL_ASC); + + foreach ($collection as $product) { + if ($product instanceof MagentoProduct) { + $found[] = $product; + } + } + } + + $products = $this->convertAll($found); + + /** @var CatalogSearchSearchResponseInterface $response */ + $response = $this->searchResponseFactory->create(); + $response->setUcp($this->ucp()); + $response->setProducts($products); + $response->setPagination($this->pagination($offset + $pageSize, $total)); + + return $response; + } + + /** + * @inheritDoc + */ + public function lookup(array $ids): CatalogLookupLookupResponseInterface + { + // Capped at the same size as a search page, because the endpoint is public and every extra id + // used to mean another product load in the same request. + $skus = array_map( + static fn (mixed $id): string => (string) $id, + array_slice(array_values($ids), 0, self::MAX_LIMIT) + ); + + $found = $this->findBySkus($skus); + $matched = []; + $requested = []; + + foreach ($skus as $sku) { + $product = $found[strtolower($sku)] ?? null; + + // An id that matches nothing is left out rather than failing the batch: an agent asking about + // several products should still learn about the ones that exist. + if ($product !== null) { + $matched[] = $product; + $requested[] = $sku; + } + } + + $products = []; + + foreach ($this->convertAll($matched) as $index => $converted) { + $products[] = $this->correlate($converted, $requested[$index]); + } + + /** @var CatalogLookupLookupResponseInterface $response */ + $response = $this->lookupResponseFactory->create(); + $response->setUcp($this->ucp()); + $response->setProducts($products); + + return $response; + } + + /** + * @inheritDoc + * @throws UcpException + */ + public function getProduct(string $id): CatalogLookupGetProductResponseInterface + { + $product = $this->findBySku($id); + + if ($product === null) { + throw new UcpException(__('Product not found: %1.', $id), 'not_found', 404); + } + + /** @var CatalogLookupGetProductResponseInterface $response */ + $response = $this->productResponseFactory->create(); + $response->setUcp($this->ucp()); + $response->setProduct($this->asDetail($this->convertAll([$product])[0])); + + return $response; + } + + /** + * A lookup variant has to say which requested identifier resolved to it: `exact` when the id names + * the variant itself, `featured` when the server chose it on the caller's behalf. + * + * @param ProductInterface $product + * @param string $requestedId + * @return ProductInterface + */ + private function correlate(ProductInterface $product, string $requestedId): ProductInterface + { + foreach ($product->getVariants() as $variant) { + if (!$variant instanceof SpecObject) { + continue; + } + + $match = $variant->getSku() === $requestedId ? self::MATCH_EXACT : self::MATCH_FEATURED; + + $variant->set(self::INPUTS_KEY, [$this->correlationFactory->create(['data' => [ + InputCorrelationInterface::KEY_ID => $requestedId, + InputCorrelationInterface::KEY_MATCH => $match, + ]])]); + } + + return $product; + } + + /** + * Loads a whole batch of SKUs with one query. Keyed without case, the way the database compares + * SKUs, so a differently cased id still finds its product. + * + * @param string[] $skus + * @return array + */ + private function findBySkus(array $skus): array + { + if ($skus === []) { + return []; + } + + $collection = $this->visibleProducts(); + $collection->addAttributeToFilter('sku', ['in' => $skus]); + + $found = []; + + foreach ($collection as $product) { + if ($product instanceof MagentoProduct) { + $found[strtolower((string) $product->getSku())] = $product; + } + } + + return $found; + } + + /** + * The single-product response takes the richer detail type, which is the product plus option + * selections. The extra fields need configurable option axes we do not read yet. + * + * @param ProductInterface $product + * @return CatalogLookupDetailProductInterface + */ + private function asDetail(ProductInterface $product): CatalogLookupDetailProductInterface + { + $data = $product instanceof SpecObject ? $product->toArray() : []; + + return $this->detailProductFactory->create(['data' => $data]); + } + + /** + * Converts a whole page in one go. The variants behind it and the stock behind those are read once + * for the page; reading them per product cost a query for every parent and two for every variant. + * + * @param MagentoProduct[] $products + * @return ProductInterface[] + */ + private function convertAll(array $products): array + { + $children = $this->childrenFor($products); + $currencyCode = $this->currencyCode(); + $linkField = $this->metadataPool->getMetadata(CatalogProductInterface::class)->getLinkField(); + + $converted = []; + + foreach ($products as $product) { + $converted[] = $this->productConverter->convert( + $product, + $currencyCode, + $children[$this->idOf($product->getData($linkField))] ?? [], + $this->urlOf($product), + $this->imageOf($product) + ); + } + + return $converted; + } + + /** + * Loads the variants of every configurable product on the page: one query for which variant + * belongs to which product, and one for the variants themselves. + * + * @param MagentoProduct[] $products + * @return array Variants, keyed by the product they belong to + */ + private function childrenFor(array $products): array + { + $linkField = $this->metadataPool->getMetadata(CatalogProductInterface::class)->getLinkField(); + $parentIds = []; + + foreach ($products as $product) { + if ($product->getTypeId() === Configurable::TYPE_CODE) { + $parentIds[] = $this->idOf($product->getData($linkField)); + } + } + + $links = $this->variantLinks(array_filter($parentIds)); + + if ($links === []) { + return []; + } + + $loaded = $this->loadVariants(array_unique(array_merge(...array_values($links)))); + $children = []; + + foreach ($links as $parentId => $variantIds) { + foreach ($variantIds as $variantId) { + // A variant the query left out, because it is out of stock or needs options chosen, + // is not offered here either. + if (isset($loaded[$variantId])) { + $children[$parentId][] = $loaded[$variantId]; + } + } + } + + return $children; + } + + /** + * @param mixed $value + * @return int A product id, or zero when there is none to read + */ + private function idOf(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } + + /** + * Which variant belongs to which product. Read from the link table rather than through Magento's + * variant collection, because that collection cannot hold a variant shared by two products. + * + * @param int[] $parentIds + * @return array Variant ids, keyed by the product they belong to + */ + private function variantLinks(array $parentIds): array + { + if ($parentIds === []) { + return []; + } + + $connection = $this->configurableResource->getConnection(); + + if (!$connection instanceof AdapterInterface) { + return []; + } + + $select = $connection->select() + ->from($this->configurableResource->getMainTable(), ['parent_id', 'product_id']) + ->where('parent_id IN (?)', $parentIds); + + $links = []; + + foreach ($connection->fetchAll($select) as $row) { + $links[$this->idOf($row['parent_id'])][] = $this->idOf($row['product_id']); + } + + return $links; + } + + /** + * @param int[] $variantIds + * @return array Variants, keyed by their own id + */ + private function loadVariants(array $variantIds): array + { + $storeId = (int) $this->storeManager->getStore()->getId(); + + $collection = $this->collectionFactory->create(); + $collection->addIdFilter($variantIds); + // Description is left out on purpose: a variant would repeat its parent's whole description, + // which says nothing new and makes the answer much bigger. + $collection->addAttributeToSelect(['name', 'price', 'special_price', 'status', 'visibility']); + $collection->addFilterByRequiredOptions(); + $collection->addStoreFilter($storeId); + $collection->setStoreId($storeId); + + $variants = []; + + foreach ($collection->getItems() as $variant) { + if ($variant instanceof MagentoProduct) { + $variants[(int) $variant->getId()] = $variant; + } + } + + return $variants; + } + + /** + * Read through the same visible-products query as search, so a product the storefront hides is not + * something an agent can pull out by knowing its SKU. + * + * @param string $sku + * @return MagentoProduct|null + */ + private function findBySku(string $sku): ?MagentoProduct + { + return $this->findBySkus([$sku])[strtolower($sku)] ?? null; + } + + /** + * Reads the ids for one page on a query of its own. The name-or-sku filter joins an attribute + * table, which can list one product on several rows, so limiting the main query would cut the + * page out of rows rather than products and hand back a short page. + * + * @param ProductCollection $collection Collection carrying the search filters + * @param int $pageSize How many products the page holds + * @param int $offset How many products to skip + * @return int[] + */ + private function pageIds(ProductCollection $collection, int $pageSize, int $offset): array + { + $select = $this->bareSelect($collection); + $select->distinct(true); + $select->columns('e.entity_id'); + $select->order('e.entity_id ' . Select::SQL_ASC); + $select->limit($pageSize, $offset); + + return array_map('intval', $collection->getConnection()->fetchCol($select)); + } + + /** + * Counts distinct ids on the collection's own query. The name-or-sku filter joins an attribute + * table, which can list one product on several rows. + * + * @param ProductCollection $collection + * @return int + */ + private function totalOf(ProductCollection $collection): int + { + $select = $this->bareSelect($collection); + $select->columns(new Expression('COUNT(DISTINCT e.entity_id)')); + + return (int) $collection->getConnection()->fetchOne($select); + } + + /** + * The collection's query with everything but its filters stripped, so a caller can ask its own + * question of the same rows. + * + * @param ProductCollection $collection + * @return Select + */ + private function bareSelect(ProductCollection $collection): Select + { + $select = clone $collection->getSelect(); + $select->reset(Select::COLUMNS); + $select->reset(Select::ORDER); + $select->reset(Select::GROUP); + $select->reset(Select::LIMIT_COUNT); + $select->reset(Select::LIMIT_OFFSET); + + return $select; + } + + /** + * @return ProductCollection + */ + private function visibleProducts(): ProductCollection + { + $collection = $this->collectionFactory->create(); + $collection->addAttributeToSelect(['name', 'description', 'price', 'image', 'status', 'visibility']); + $collection->addAttributeToFilter('status', ['eq' => Status::STATUS_ENABLED]); + $collection->setVisibility($this->visibility->getVisibleInSiteIds()); + $collection->addStoreFilter((int) $this->storeManager->getStore()->getId()); + + // Reads the whole page's storefront addresses at once. Asking each product for its own url + // meant another query per product. + $collection->addUrlRewrite(); + + // Out-of-stock products are kept and reported with `availability.available = false`. Magento + // would otherwise drop them at load but not from a count, so the two disagreed; the spec models + // out-of-stock explicitly, and an agent is better told a product exists than left guessing. + $collection->setFlag(self::STOCK_FILTER_FLAG, true); + + // Reads every product's stock as part of this same query. The flag above stops Magento adding + // the join a second time, and `false` keeps out-of-stock rows rather than filtering them. + $this->stockStatus->addStockDataToCollection($collection, false); + + return $collection; + } + + /** + * @param int|null $limit + * @return int + */ + private function pageSize(?int $limit): int + { + if ($limit === null || $limit < 1) { + return self::DEFAULT_LIMIT; + } + + return min($limit, self::MAX_LIMIT); + } + + /** + * @param string|null $cursor + * @return int + */ + private function offsetFrom(?string $cursor): int + { + if ($cursor === null || !str_starts_with($cursor, self::CURSOR_PREFIX)) { + return 0; + } + + $offset = (int) substr($cursor, strlen(self::CURSOR_PREFIX)); + + return max(0, $offset); + } + + /** + * @param int $nextOffset + * @param int $total + * @return PaginationResponseInterface + */ + private function pagination(int $nextOffset, int $total): PaginationResponseInterface + { + $hasNext = $nextOffset < $total; + + /** @var PaginationResponseInterface $pagination */ + $pagination = $this->paginationFactory->create(); + $pagination->setHasNextPage($hasNext); + $pagination->setTotalCount($total); + + if ($hasNext) { + $pagination->setCursor(self::CURSOR_PREFIX . $nextOffset); + } + + return $pagination; + } + + /** + * @return UcpResponseCatalogSchemaInterface + */ + private function ucp(): UcpResponseCatalogSchemaInterface + { + $service = $this->serviceRegistry->getService('dev.ucp.shopping'); + + return $this->ucpCatalogFactory->create([ + 'data' => [ + UcpResponseCatalogSchemaInterface::KEY_VERSION => UniversalCommerceProtocolInterface::SPEC_VERSION, + UcpResponseCatalogSchemaInterface::KEY_CAPABILITIES => $service ? $service->getCapabilities() : [], + ] + ]); + } + + /** + * @return string + */ + private function currencyCode(): string + { + $store = $this->storeManager->getStore(); + + return $store instanceof Store ? (string) $store->getCurrentCurrencyCode() : self::FALLBACK_CURRENCY; + } + + /** + * @param MagentoProduct $product + * @return string + */ + private function urlOf(MagentoProduct $product): string + { + return (string) $product->getProductUrl(); + } + + /** + * @param MagentoProduct $product + * @return string + */ + private function imageOf(MagentoProduct $product): string + { + return (string) $this->imageHelper->init($product, 'product_page_image_large')->getUrl(); + } +} diff --git a/Model/Service/Shopping/CheckoutDataProcessor.php b/Model/Service/Shopping/CheckoutDataProcessor.php index 57d41c4..2da4113 100644 --- a/Model/Service/Shopping/CheckoutDataProcessor.php +++ b/Model/Service/Shopping/CheckoutDataProcessor.php @@ -20,18 +20,22 @@ use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\Types\PostalAddressInterface; +use Magebit\AgenticCore\Model\Buyer\BuyerIdentity; +use Magebit\AgenticCore\Model\Buyer\BuyerWriter; +use Magebit\AgenticCore\Model\Quote\AddressWriter; +use Magebit\AgenticCore\Model\Quote\LineItemOutcome; +use Magebit\AgenticCore\Model\Quote\LineItemResult; +use Magebit\AgenticCore\Model\Quote\LineItemWriter; +use Magebit\AgenticCore\Model\Quote\PersonalInformationCopier; +use Magebit\AgenticCore\Model\Quote\PostalAddress as CorePostalAddress; +use Magebit\AgenticCore\Model\Quote\ShippingMethodWriter; use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutCreateRequestInterface; use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutUpdateRequestInterface; use Magebit\UniversalCommerce\Api\Service\Shopping\QuoteValidatorInterface; -use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\Catalog\Model\Product; use Magento\Framework\App\Request\Http; -use Magento\Framework\Exception\LocalizedException; -use Magento\Framework\Exception\NoSuchEntityException; use Magento\Quote\Api\Data\CartInterface; use Magento\Quote\Api\GuestCouponManagementInterface; use Magento\Quote\Model\Quote; -use Magento\Quote\Model\Quote\Address; class CheckoutDataProcessor implements QuoteValidatorInterface { @@ -44,21 +48,33 @@ class CheckoutDataProcessor implements QuoteValidatorInterface public const CODE_INVALID = 'invalid'; + public const CODE_NOT_FOUND = 'not_found'; + public const CODE_OUT_OF_STOCK = 'out_of_stock'; + public const CODE_NOT_APPLIED = 'not_applied'; + /** - * @param ProductRepositoryInterface $productRepository + * @param LineItemWriter $lineItemWriter + * @param AddressWriter $addressWriter + * @param PersonalInformationCopier $personalInformationCopier + * @param ShippingMethodWriter $shippingMethodWriter * @param GuestCouponManagementInterface $guestCouponManagement * @param AgentProfileParser $agentProfileParser * @param Http $httpRequest * @param MessageInterfaceFactory $messageFactory + * @param BuyerWriter $buyerWriter */ public function __construct( - protected readonly ProductRepositoryInterface $productRepository, + protected readonly LineItemWriter $lineItemWriter, + protected readonly AddressWriter $addressWriter, + protected readonly PersonalInformationCopier $personalInformationCopier, + protected readonly ShippingMethodWriter $shippingMethodWriter, protected readonly GuestCouponManagementInterface $guestCouponManagement, protected readonly AgentProfileParser $agentProfileParser, protected readonly Http $httpRequest, - protected readonly MessageInterfaceFactory $messageFactory + protected readonly MessageInterfaceFactory $messageFactory, + protected readonly BuyerWriter $buyerWriter ) { } @@ -88,6 +104,7 @@ public function processCreateCheckoutRequest( $this->processFulfillmentInformation($cart, $request->getFulfillment()); } + $this->fillBillingFromShipping($cart); $this->resolveBillingCountry($cart); if ($request->getDiscounts()) { @@ -121,6 +138,7 @@ public function processUpdateCheckoutRequest( $this->processFulfillmentInformation($cart, $request->getFulfillment()); } + $this->fillBillingFromShipping($cart); $this->resolveBillingCountry($cart); if ($request->getDiscounts()) { @@ -148,23 +166,22 @@ public function validate(CartInterface $quote): array|null } /** - * Process agent profile + * The URL the agent wants order lifecycle events sent to, from the profile its `UCP-Agent` header + * points at. Persisted with the checkout so it is still available when the order moves. * - * @return void + * @return string|null */ - public function processAgentProfile(): void + public function getAgentWebhookUrl(): ?string { $ucpAgentHeader = $this->httpRequest->getHeader('UCP-Agent'); - if (!is_string($ucpAgentHeader)) { - return; + if (!is_string($ucpAgentHeader) || $ucpAgentHeader === '') { + return null; } - $agentProfile = $this->agentProfileParser->parse($ucpAgentHeader); - - if (!$agentProfile) { - return; - } + // The parser caps the fetch at five seconds, follows at most three redirects and caches for an + // hour, so resolving the profile inline costs a bounded miss rather than an open-ended wait. + return $this->agentProfileParser->parseWebhookUrl($ucpAgentHeader); } /** @@ -177,35 +194,17 @@ public function processAgentProfile(): void public function processBuyerInformation(CartInterface $cart, BuyerInterface $buyer): void { /** @var Quote $cart */ - if ($buyer->getEmail()) { - $cart->setCustomerEmail($buyer->getEmail()); - } - - if ($buyer->getFirstName()) { - $cart->setCustomerFirstname($buyer->getFirstName()); - } - - if ($buyer->getLastName()) { - $cart->setCustomerLastname($buyer->getLastName()); - } - - $billingAddress = $cart->getBillingAddress(); - - if ($buyer->getEmail()) { - $billingAddress->setEmail($buyer->getEmail()); - } - - if ($buyer->getFirstName()) { - $billingAddress->setFirstname($buyer->getFirstName()); - } - - if ($buyer->getLastName()) { - $billingAddress->setLastname($buyer->getLastName()); - } - - if ($buyer->getPhoneNumber()) { - $billingAddress->setTelephone($buyer->getPhoneNumber()); - } + $identity = new BuyerIdentity( + $buyer->getFirstName(), + $buyer->getLastName(), + $buyer->getEmail(), + $buyer->getPhoneNumber() + ); + + $this->buyerWriter->writeCustomer($cart, $identity); + // The billing address, not the shipping one: this protocol carries the delivery destination + // inside the fulfillment request instead. + $this->buyerWriter->writeContact($cart->getBillingAddress(), $identity); } /** @@ -218,60 +217,100 @@ public function processBuyerInformation(CartInterface $cart, BuyerInterface $buy public function processLineItems(CartInterface $cart, array $lineItems): void { /** @var Quote $cart */ - $cart->removeAllItems(); - - foreach (array_values($lineItems) as $index => $lineItem) { - $sku = $lineItem->getItem()->getId(); - $path = sprintf('$.line_items[%d].item.id', $index); - $product = $this->loadProduct($cart, $sku); - - if (!$product) { - $this->addMessage($cart, self::CODE_INVALID, $path, sprintf('Product "%s" does not exist.', $sku)); - continue; - } + $items = array_map( + fn ($lineItem): array => [ + 'sku' => (string) $lineItem->getItem()->getId(), + 'quantity' => $lineItem->getQuantity(), + ], + array_values($lineItems) + ); - if (!$product->isSalable()) { - $this->addMessage( - $cart, - self::CODE_OUT_OF_STOCK, - $path, - sprintf('Product "%s" is not available for purchase.', $sku) - ); - continue; - } - - $this->addProductToCart($cart, $product, $lineItem->getQuantity(), $path); + foreach ($this->lineItemWriter->write($cart, $items) as $result) { + $this->reportLineItem($cart, $result); } } /** - * Copy personal information from billing to shipping address + * The match is exhaustive over the enum on purpose: a fifth outcome in the shared writer becomes a + * visible gap here rather than a silently dropped message. + * + * @param Quote $cart + * @param LineItemResult $result + * @return void + */ + private function reportLineItem(Quote $cart, LineItemResult $result): void + { + $path = sprintf('$.line_items[%d].item.id', $result->index); + + match ($result->outcome) { + LineItemOutcome::Added => null, + LineItemOutcome::NotFound => $this->addMessage( + $cart, + self::CODE_NOT_FOUND, + $path, + sprintf('Product "%s" does not exist.', $result->sku) + ), + LineItemOutcome::NotSalable => $this->addMessage( + $cart, + self::CODE_OUT_OF_STOCK, + $path, + sprintf('Product "%s" is not available for purchase.', $result->sku) + ), + LineItemOutcome::InsufficientStock => $this->addMessage( + $cart, + self::CODE_OUT_OF_STOCK, + $path, + $result->reason ?? sprintf('Product "%s" is not available in that quantity.', $result->sku) + ), + LineItemOutcome::InvalidQuantity => $this->addMessage( + $cart, + self::CODE_INVALID, + $path, + $result->reason ?? sprintf('Product "%s" cannot be bought in that quantity.', $result->sku) + ), + LineItemOutcome::Rejected => $this->addMessage( + $cart, + self::CODE_INVALID, + $path, + $result->reason ?? sprintf('Product "%s" could not be added.', $result->sku) + ), + }; + } + + /** + * A store bills where it ships unless the agent named a billing address of its own — the same thing + * a storefront does when the buyer leaves "same as shipping" ticked. Without it a checkout that + * carries only a destination has no billing address at all, and Magento refuses the order. * * @param CartInterface $cart * @return void */ - public function copyPersonalInformationFromBillingToShipping(CartInterface $cart): void + public function fillBillingFromShipping(CartInterface $cart): void { /** @var Quote $cart */ - $billingAddress = $cart->getBillingAddress(); - $shippingAddress = $cart->getShippingAddress(); - - if ($billingAddress->getFirstname()) { - $shippingAddress->setFirstname($billingAddress->getFirstname()); + if ($cart->getIsVirtual()) { + return; } - if ($billingAddress->getLastname()) { - $shippingAddress->setLastname($billingAddress->getLastname()); - } + $billingAddress = $cart->getBillingAddress(); - if ($billingAddress->getEmail()) { - $shippingAddress->setEmail($billingAddress->getEmail()); + if (trim((string) $billingAddress->getStreetLine(1)) !== '') { + return; } - // Quote addresses store the phone under `telephone`; getPhoneNumber() resolved to an unset data key. - if ($billingAddress->getTelephone()) { - $shippingAddress->setTelephone($billingAddress->getTelephone()); - } + $this->personalInformationCopier->copyPostalFields($cart->getShippingAddress(), $billingAddress); + } + + /** + * Copy personal information from billing to shipping address + * + * @param CartInterface $cart + * @return void + */ + public function copyPersonalInformationFromBillingToShipping(CartInterface $cart): void + { + /** @var Quote $cart */ + $this->personalInformationCopier->copyIdentity($cart->getBillingAddress(), $cart->getShippingAddress()); } /** @@ -312,17 +351,7 @@ public function processFulfillmentInformation(CartInterface $cart, FulfillmentRe public function setShippingMethodToCart(CartInterface $cart, string $shippingMethod): void { /** @var Quote $cart */ - $shippingAddress = $cart->getShippingAddress(); - $shippingAddress->setShippingMethod($shippingMethod); - - $cartExtension = $cart->getExtensionAttributes(); - if ($cartExtension && $cartExtension->getShippingAssignments()) { - $cartExtension->getShippingAssignments()[0] - ->getShipping() - ->setMethod($shippingMethod); - } - - $shippingAddress->setCollectShippingRates(true); + $this->shippingMethodWriter->write($cart, $shippingMethod); } /** @@ -336,7 +365,7 @@ public function addDestinationToCart(CartInterface $cart, FulfillmentDestination { /** @var Quote $cart */ $shippingAddress = $cart->getShippingAddress(); - $this->applyAddressFields($shippingAddress, $destination); + $this->addressWriter->write($shippingAddress, $this->toPostalAddress($destination)); // Rates cached against the previous destination are stale once the address moves. $shippingAddress->setCollectShippingRates(true); @@ -373,6 +402,17 @@ public function processDiscountInformation( return; } + // Magento carries one coupon per cart, so anything past the first is reported rather than + // dropped: an agent that sent two codes has to be told which one counted. + foreach (array_slice(array_values($codes), 1) as $index => $unused) { + $this->addMessage( + $cart, + self::CODE_NOT_APPLIED, + sprintf('$.discounts.codes[%d]', $index + 1), + sprintf('This store applies one discount code at a time, so "%s" was not applied.', $unused) + ); + } + try { $this->guestCouponManagement->set($maskedCartId, $couponCode); $cart->collectTotals(); @@ -392,7 +432,7 @@ public function processDiscountInformation( public function processBillingAddress(CartInterface $cart, PostalAddressInterface $address): void { /** @var Quote $cart */ - $this->applyAddressFields($cart->getBillingAddress(), $address); + $this->addressWriter->write($cart->getBillingAddress(), $this->toPostalAddress($address)); } /** @@ -418,49 +458,25 @@ public function resolveBillingCountry(CartInterface $cart): void } /** - * Copy the UCP postal fields onto a quote address + * Turns either submitted address type into the shared writer's protocol-free value object. * - * @param Address $address * @param FulfillmentDestinationRequestInterface|PostalAddressInterface $source - * @return void + * @return CorePostalAddress */ - private function applyAddressFields( - Address $address, + private function toPostalAddress( FulfillmentDestinationRequestInterface|PostalAddressInterface $source - ): void { - $street = array_values(array_filter([$source->getStreetAddress(), $source->getExtendedAddress()])); - - if ($street) { - $address->setStreet($street); - } - - if ($source->getAddressLocality()) { - $address->setCity($source->getAddressLocality()); - } - - if ($source->getAddressRegion()) { - $address->setRegion($source->getAddressRegion()); - } - - if ($source->getAddressCountry()) { - $address->setCountryId($source->getAddressCountry()); - } - - if ($source->getPostalCode()) { - $address->setPostcode($source->getPostalCode()); - } - - if ($source->getFirstName()) { - $address->setFirstname($source->getFirstName()); - } - - if ($source->getLastName()) { - $address->setLastname($source->getLastName()); - } - - if ($source->getPhoneNumber()) { - $address->setTelephone($source->getPhoneNumber()); - } + ): CorePostalAddress { + return new CorePostalAddress( + streetLine: $source->getStreetAddress(), + extendedLine: $source->getExtendedAddress(), + locality: $source->getAddressLocality(), + region: $source->getAddressRegion(), + country: $source->getAddressCountry(), + postalCode: $source->getPostalCode(), + firstName: $source->getFirstName(), + lastName: $source->getLastName(), + phone: $source->getPhoneNumber() + ); } /** @@ -512,46 +528,6 @@ private function selectDestination( return $destinations[0]; } - /** - * @param Quote $cart - * @param string $sku - * @return Product|null - */ - private function loadProduct(Quote $cart, string $sku): ?Product - { - try { - /** @var Product $product */ - $product = $this->productRepository->get($sku, false, (int) $cart->getStoreId()); - } catch (NoSuchEntityException $e) { - unset($e); - return null; - } - - return $product; - } - - /** - * @param Quote $cart - * @param Product $product - * @param int $quantity - * @param string $path - * @return void - */ - private function addProductToCart(Quote $cart, Product $product, int $quantity, string $path): void - { - try { - $result = $cart->addProduct($product, $quantity); - } catch (LocalizedException $e) { - $this->addMessage($cart, self::CODE_INVALID, $path, $e->getMessage()); - return; - } - - // Quote::addProduct hands back a string instead of an item when the product cannot be configured. - if (is_string($result)) { - $this->addMessage($cart, self::CODE_INVALID, $path, $result); - } - } - /** * @param Quote $cart * @param string $code diff --git a/Model/Service/Shopping/CheckoutGuard.php b/Model/Service/Shopping/CheckoutGuard.php new file mode 100644 index 0000000..997fd7e --- /dev/null +++ b/Model/Service/Shopping/CheckoutGuard.php @@ -0,0 +1,78 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping; + +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; +use Magebit\AgenticCore\Model\Checkout\CheckoutState; +use Magebit\AgenticCore\Model\Checkout\StateResolver; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magento\Quote\Api\Data\CartInterface; + +/** + * Refuses changes to a checkout that has already finished, so an agent is told the session is closed + * rather than being handed a success it cannot act on. + */ +class CheckoutGuard +{ + public const CODE_COMPLETED = 'checkout_completed'; + + public const CODE_CANCELED = 'checkout_canceled'; + + /** + * @param StateResolver $stateResolver + * @param OrderLinkRepositoryInterface $orderLinkRepository + */ + public function __construct( + private readonly StateResolver $stateResolver, + private readonly OrderLinkRepositoryInterface $orderLinkRepository + ) { + } + + /** + * @param CartInterface $quote + * @param string $checkoutId + * @return void + * @throws UcpException When the session has already been completed or canceled + */ + public function assertOpen(CartInterface $quote, string $checkoutId): void + { + if ($this->isCompleted($quote, $checkoutId)) { + throw new UcpException( + __('Checkout session %1 is already completed and can no longer be changed.', $checkoutId), + self::CODE_COMPLETED, + 409 + ); + } + + if ($this->stateResolver->resolve($quote, false, false) === CheckoutState::Canceled) { + throw new UcpException( + __('Checkout session %1 is canceled and can no longer be changed.', $checkoutId), + self::CODE_CANCELED, + 409 + ); + } + } + + /** + * @param CartInterface $quote + * @param string $checkoutId + * @return bool + */ + public function isCompleted(CartInterface $quote, string $checkoutId): bool + { + $hasOrder = $this->orderLinkRepository->findOrderId(IdempotencyHandler::SCOPE, $checkoutId) !== null; + + return $this->stateResolver->resolve($quote, $hasOrder, false) === CheckoutState::Completed; + } +} diff --git a/Model/Service/Shopping/Converter/CartMessageDowngrade.php b/Model/Service/Shopping/Converter/CartMessageDowngrade.php new file mode 100644 index 0000000..342db0a --- /dev/null +++ b/Model/Service/Shopping/Converter/CartMessageDowngrade.php @@ -0,0 +1,65 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageInfoInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInfoInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; + +/** + * Reports checkout validation errors as warnings on a cart. The spec has a cart return informational + * messages: a missing delivery address is something checkout will need, not a fault in the cart. + */ +class CartMessageDowngrade +{ + /** + * @param MessageInfoInterfaceFactory $messageInfoFactory + */ + public function __construct( + private readonly MessageInfoInterfaceFactory $messageInfoFactory + ) { + } + + /** + * Keyed on the message's own `type` rather than its PHP class: the validators build the generic + * message type, and the generated error and info types share no interface. + * + * @param array $messages + * @return array + */ + public function apply(array $messages): array + { + return array_map(fn (object $message): object => $this->downgrade($message), $messages); + } + + /** + * @param object $message + * @return object + */ + private function downgrade(object $message): object + { + if (!$message instanceof MessageInterface || $message->getType() !== MessageInterface::TYPE_ERROR) { + return $message; + } + + return $this->messageInfoFactory->create([ + 'data' => array_filter([ + MessageInfoInterface::KEY_TYPE => MessageInfoInterface::TYPE_INFO, + MessageInfoInterface::KEY_CODE => $message->getCode(), + MessageInfoInterface::KEY_CONTENT => $message->getContent(), + MessageInfoInterface::KEY_CONTENT_TYPE => $message->getContentType(), + MessageInfoInterface::KEY_PATH => $message->getPath(), + ], static fn (mixed $value): bool => $value !== null), + ]); + } +} diff --git a/Model/Service/Shopping/Converter/OrderAddressToPostalAddress.php b/Model/Service/Shopping/Converter/OrderAddressToPostalAddress.php new file mode 100644 index 0000000..e4a0e56 --- /dev/null +++ b/Model/Service/Shopping/Converter/OrderAddressToPostalAddress.php @@ -0,0 +1,91 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\UcpSpec\Api\Shopping\Types\PostalAddressInterface; +use Magebit\UcpSpec\Api\Shopping\Types\PostalAddressInterfaceFactory; +use Magento\Sales\Api\Data\OrderAddressInterface; + +/** + * An order address as the spec's postal address. Every field is optional, so a field the address has no + * value for is left out rather than sent as an empty string. + */ +class OrderAddressToPostalAddress +{ + /** + * @param PostalAddressInterfaceFactory $postalAddressFactory + */ + public function __construct( + private readonly PostalAddressInterfaceFactory $postalAddressFactory + ) { + } + + /** + * @param OrderAddressInterface $address + * @return PostalAddressInterface + */ + public function convert(OrderAddressInterface $address): PostalAddressInterface + { + $street = $address->getStreet() ?? []; + + /** @var PostalAddressInterface $postalAddress */ + $postalAddress = $this->postalAddressFactory->create(); + + if ($this->present($street[0] ?? null)) { + $postalAddress->setStreetAddress((string) $street[0]); + } + + if ($this->present($street[1] ?? null)) { + $postalAddress->setExtendedAddress((string) $street[1]); + } + + if ($this->present($address->getCity())) { + $postalAddress->setAddressLocality((string) $address->getCity()); + } + + if ($this->present($address->getRegion())) { + $postalAddress->setAddressRegion((string) $address->getRegion()); + } + + if ($this->present($address->getCountryId())) { + $postalAddress->setAddressCountry((string) $address->getCountryId()); + } + + if ($this->present($address->getPostcode())) { + $postalAddress->setPostalCode((string) $address->getPostcode()); + } + + if ($this->present($address->getFirstname())) { + $postalAddress->setFirstName((string) $address->getFirstname()); + } + + if ($this->present($address->getLastname())) { + $postalAddress->setLastName((string) $address->getLastname()); + } + + if ($this->present($address->getTelephone())) { + $postalAddress->setPhoneNumber((string) $address->getTelephone()); + } + + return $postalAddress; + } + + /** + * @param mixed $value + * @return bool + */ + private function present(mixed $value): bool + { + return is_scalar($value) && (string) $value !== ''; + } +} diff --git a/Model/Service/Shopping/Converter/OrderItemToOrderLineItem.php b/Model/Service/Shopping/Converter/OrderItemToOrderLineItem.php new file mode 100644 index 0000000..a1241a7 --- /dev/null +++ b/Model/Service/Shopping/Converter/OrderItemToOrderLineItem.php @@ -0,0 +1,327 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\ItemResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\ItemResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemInterface; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemQuantityInterface; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemQuantityInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\Sales\Api\Data\OrderItemInterface; + +/** + * Maps one order item onto an order line item, including the quantity tracking the checkout's line + * items have no equivalent of. + */ +class OrderItemToOrderLineItem +{ + /** + * Image the thumbnail is taken from, the same one the storefront shows on a product page. + */ + private const IMAGE_ID = 'product_base_image'; + + /** + * Tells Magento the stock filter is already handled, so an out-of-stock product keeps its picture. + */ + private const STOCK_FILTER_FLAG = 'has_stock_status_filter'; + + /** + * @param OrderLineItemInterfaceFactory $lineItemFactory + * @param OrderLineItemQuantityInterfaceFactory $quantityFactory + * @param ItemResponseInterfaceFactory $itemFactory + * @param TotalResponseInterfaceFactory $totalFactory + * @param CollectionFactory $productCollectionFactory + * @param ImageHelper $imageHelper + * @param MinorUnits $minorUnits + */ + public function __construct( + private readonly OrderLineItemInterfaceFactory $lineItemFactory, + private readonly OrderLineItemQuantityInterfaceFactory $quantityFactory, + private readonly ItemResponseInterfaceFactory $itemFactory, + private readonly TotalResponseInterfaceFactory $totalFactory, + private readonly CollectionFactory $productCollectionFactory, + private readonly ImageHelper $imageHelper, + private readonly MinorUnits $minorUnits + ) { + } + + /** + * Looks the pictures up for a whole order in one product load, so a long order does not load a + * product per line. A product deleted since the order was placed is simply absent from the result. + * + * @param OrderItemInterface[] $orderItems + * @return array Product id to the URL of its picture + */ + public function imageUrls(array $orderItems): array + { + $productIds = []; + $storeId = 0; + + foreach ($orderItems as $orderItem) { + $productId = (int) $orderItem->getProductId(); + + if ($productId === 0) { + continue; + } + + $productIds[$productId] = $productId; + // Every item of an order belongs to the same store. + $storeId = (int) $orderItem->getStoreId(); + } + + if ($productIds === []) { + return []; + } + + $urls = []; + + foreach ($this->productCollection($productIds, $storeId)->getItems() as $product) { + if (!$product instanceof Product) { + continue; + } + + $url = $this->imageUrlOf($product); + + if ($url !== null) { + $urls[(int) $product->getId()] = $url; + } + } + + return $urls; + } + + /** + * @param OrderItemInterface $orderItem + * @param string $currencyCode + * @param string $lineItemId Identifier the response exposes for this item + * @param string|null $parentId Identifier of the enclosing line item, for nested products + * @param string|null $imageUrl Picture already looked up for this item, from `imageUrls()` + * @return OrderLineItemInterface + */ + public function convert( + OrderItemInterface $orderItem, + string $currencyCode, + string $lineItemId, + ?string $parentId = null, + ?string $imageUrl = null + ): OrderLineItemInterface { + $quantity = $this->convertQuantity($orderItem); + + /** @var OrderLineItemInterface $lineItem */ + $lineItem = $this->lineItemFactory->create(); + $lineItem->setId($lineItemId); + $lineItem->setItem($this->convertItem($orderItem, $currencyCode, $imageUrl)); + $lineItem->setQuantity($quantity); + $lineItem->setTotals($this->convertTotals($orderItem, $currencyCode)); + $lineItem->setStatus($this->deriveStatus($quantity)); + + if ($parentId !== null) { + $lineItem->setParentId($parentId); + } + + return $lineItem; + } + + /** + * `total` is what remains after cancellations and returns, so an agent reading only it sees the + * order as it stands; `original` preserves what the checkout sold. + * + * The spec types all three as integers, so a fractional quantity is reported rounded — the same + * limitation the checkout's line items have. + * + * @param OrderItemInterface $orderItem + * @return OrderLineItemQuantityInterface + */ + public function convertQuantity(OrderItemInterface $orderItem): OrderLineItemQuantityInterface + { + $ordered = (float) $orderItem->getQtyOrdered(); + $removed = (float) $orderItem->getQtyCanceled() + (float) $orderItem->getQtyRefunded(); + $total = max(0.0, $ordered - $removed); + + // Capped at what is still active: Magento keeps the shipped quantity after a return, and + // reporting more fulfilled than remains would read as a quantity that is both gone and in hand. + $fulfilled = min($total, $this->fulfilledQty($orderItem)); + + /** @var OrderLineItemQuantityInterface $quantity */ + $quantity = $this->quantityFactory->create(); + $quantity->setOriginal((int) round($ordered)); + $quantity->setTotal((int) round($total)); + $quantity->setFulfilled((int) round($fulfilled)); + + return $quantity; + } + + /** + * Nothing ships for a virtual item, so its shipped quantity stays at zero forever and invoicing is + * the only signal that the buyer received it. + * + * @param OrderItemInterface $orderItem + * @return float + */ + private function fulfilledQty(OrderItemInterface $orderItem): float + { + if ((bool) $orderItem->getIsVirtual()) { + return (float) $orderItem->getQtyInvoiced(); + } + + return (float) $orderItem->getQtyShipped(); + } + + /** + * The derivation is dictated by the schema, which spells out the four cases in prose. + * + * @param OrderLineItemQuantityInterface $quantity + * @return string + */ + public function deriveStatus(OrderLineItemQuantityInterface $quantity): string + { + $total = $quantity->getTotal(); + $fulfilled = $quantity->getFulfilled(); + + if ($total === 0) { + return OrderLineItemInterface::STATUS_REMOVED; + } + + if ($fulfilled >= $total) { + return OrderLineItemInterface::STATUS_FULFILLED; + } + + return $fulfilled > 0 ? OrderLineItemInterface::STATUS_PARTIAL : OrderLineItemInterface::STATUS_PROCESSING; + } + + /** + * @param OrderItemInterface $orderItem + * @param string $currencyCode + * @param string|null $imageUrl + * @return ItemResponseInterface + */ + public function convertItem( + OrderItemInterface $orderItem, + string $currencyCode, + ?string $imageUrl = null + ): ItemResponseInterface { + /** @var ItemResponseInterface $item */ + $item = $this->itemFactory->create(); + $item->setId((string) $orderItem->getSku()); + $item->setTitle((string) $orderItem->getName()); + $item->setPrice($this->minorUnits->convert((float) $orderItem->getPrice(), $currencyCode)); + + if ($imageUrl !== null) { + $item->setImageUrl($imageUrl); + } + + return $item; + } + + /** + * Magento's own formula for an item's total after discount and tax, taken from the tax module's + * item price renderer so the order agrees with what the storefront shows. + * + * @param OrderItemInterface $orderItem + * @param string $currencyCode + * @return TotalResponseInterface[] + */ + public function convertTotals(OrderItemInterface $orderItem, string $currencyCode): array + { + $rowTotal = (float) $orderItem->getRowTotal(); + $discount = abs((float) $orderItem->getDiscountAmount()); + $tax = (float) $orderItem->getTaxAmount(); + $taxCompensation = (float) $orderItem->getDiscountTaxCompensationAmount(); + + $totals = [$this->total(TotalTypeInterface::TYPE_SUBTOTAL, 'Subtotal', $rowTotal, $currencyCode)]; + + if ($discount > 0) { + $totals[] = $this->total( + TotalTypeInterface::TYPE_ITEMS_DISCOUNT, + 'Discount', + -$discount, + $currencyCode + ); + } + + if ($tax > 0) { + $totals[] = $this->total(TotalTypeInterface::TYPE_TAX, 'Tax', $tax, $currencyCode); + } + + $totals[] = $this->total( + TotalTypeInterface::TYPE_TOTAL, + 'Total', + $rowTotal - $discount + $tax + $taxCompensation, + $currencyCode + ); + + return $totals; + } + + /** + * @param string $type + * @param string $displayText + * @param float $amount + * @param string $currencyCode + * @return TotalResponseInterface + */ + private function total( + string $type, + string $displayText, + float $amount, + string $currencyCode + ): TotalResponseInterface { + /** @var TotalResponseInterface $total */ + $total = $this->totalFactory->create(); + $total->setType($type); + $total->setDisplayText($displayText); + $total->setAmount($this->minorUnits->convert($amount, $currencyCode)); + + return $total; + } + + /** + * @param int[] $productIds + * @param int $storeId + * @return ProductCollection + */ + private function productCollection(array $productIds, int $storeId): ProductCollection + { + $collection = $this->productCollectionFactory->create(); + $collection->addIdFilter(array_values($productIds)); + $collection->addAttributeToSelect(['image', 'small_image', 'thumbnail']); + $collection->setStoreId($storeId); + $collection->setFlag(self::STOCK_FILTER_FLAG, true); + + return $collection; + } + + /** + * A picture that cannot be built costs the agent only the thumbnail, so it is not worth failing on. + * + * @param Product $product + * @return string|null + */ + private function imageUrlOf(Product $product): ?string + { + try { + return $this->imageHelper->init($product, self::IMAGE_ID)->getUrl() ?: null; + } catch (\Exception $exception) { + return null; + } + } +} diff --git a/Model/Service/Shopping/Converter/OrderToAdjustments.php b/Model/Service/Shopping/Converter/OrderToAdjustments.php new file mode 100644 index 0000000..ff0a56d --- /dev/null +++ b/Model/Service/Shopping/Converter/OrderToAdjustments.php @@ -0,0 +1,326 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentInterface; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentLineItemsItemInterface; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentLineItemsItemInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; +use Magebit\UniversalCommerce\Api\OrderAdjustmentRepositoryInterface; +use Magebit\UniversalCommerce\Model\Timestamp; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Sales\Api\CreditmemoRepositoryInterface; +use Magento\Sales\Api\Data\CreditmemoInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Creditmemo; + +/** + * Post-order money movements. These sit outside the fulfillment log because a refund is not a delivery + * and can happen with no shipment at all. + */ +class OrderToAdjustments +{ + /** + * `adjustment.type` is an open string; these are the spec's documented values for the two events + * Magento records natively. + */ + public const TYPE_REFUND = 'refund'; + public const TYPE_CANCELLATION = 'cancellation'; + + /** + * @param AdjustmentInterfaceFactory $adjustmentFactory + * @param AdjustmentLineItemsItemInterfaceFactory $lineItemFactory + * @param TotalResponseInterfaceFactory $totalFactory + * @param MinorUnits $minorUnits + * @param Timestamp $timestamp + * @param CreditmemoRepositoryInterface $creditmemoRepository + * @param SearchCriteriaBuilder $searchCriteriaBuilder + * @param OrderAdjustmentRepositoryInterface $orderAdjustmentRepository + */ + public function __construct( + private readonly AdjustmentInterfaceFactory $adjustmentFactory, + private readonly AdjustmentLineItemsItemInterfaceFactory $lineItemFactory, + private readonly TotalResponseInterfaceFactory $totalFactory, + private readonly MinorUnits $minorUnits, + private readonly Timestamp $timestamp, + private readonly CreditmemoRepositoryInterface $creditmemoRepository, + private readonly SearchCriteriaBuilder $searchCriteriaBuilder, + private readonly OrderAdjustmentRepositoryInterface $orderAdjustmentRepository + ) { + } + + /** + * @param Order $order + * @param array $lineItemIds Order item id to the identifier the response exposes + * @return AdjustmentInterface[] + */ + public function convert(Order $order, array $lineItemIds): array + { + $adjustments = $this->convertRefunds($order, $lineItemIds); + $cancellation = $this->convertCancellation($order); + + if ($cancellation !== null) { + $adjustments[] = $cancellation; + } + + return array_merge($adjustments, $this->submitted($order, $this->idsOf($adjustments))); + } + + /** + * Adjustments an agent asked this store to record. What the store did itself takes precedence: an + * agent's request for a refund is not the refund, so a matching identifier reports the real one. + * + * @param Order $order + * @param string[] $recordedIds Identifiers the store's own records already account for + * @return AdjustmentInterface[] + */ + public function submitted(Order $order, array $recordedIds = []): array + { + $entityId = $order->getEntityId(); + + if (!is_numeric($entityId)) { + return []; + } + + $adjustments = []; + + foreach ($this->orderAdjustmentRepository->getByOrderId((int) $entityId) as $stored) { + $decoded = json_decode((string) $stored->getPayload(), true); + + if (!is_array($decoded) || in_array((string) $stored->getAdjustmentId(), $recordedIds, true)) { + continue; + } + + /** @var AdjustmentInterface $adjustment */ + $adjustment = $this->adjustmentFactory->create(['data' => $decoded]); + $adjustments[] = $adjustment; + } + + return $adjustments; + } + + /** + * @param AdjustmentInterface[] $adjustments + * @return string[] + */ + public function idsOf(array $adjustments): array + { + return array_map( + static fn (AdjustmentInterface $adjustment): string => $adjustment->getId(), + $adjustments + ); + } + + /** + * Everything already accounted for against this order, whether the store recorded it itself or an + * agent submitted it earlier. Used to decide whether a submission is new. + * + * @param Order $order + * @return string[] + */ + public function knownIds(Order $order): array + { + $entityId = $order->getEntityId(); + $derived = $this->convertRefunds($order, []); + $cancellation = $this->convertCancellation($order); + + if ($cancellation !== null) { + $derived[] = $cancellation; + } + + $ids = $this->idsOf($derived); + + if (!is_numeric($entityId)) { + return $ids; + } + + foreach ($this->orderAdjustmentRepository->getByOrderId((int) $entityId) as $stored) { + $ids[] = (string) $stored->getAdjustmentId(); + } + + return $ids; + } + + /** + * @param Order $order + * @param array $lineItemIds + * @return AdjustmentInterface[] + */ + public function convertRefunds(Order $order, array $lineItemIds): array + { + $adjustments = []; + $currencyCode = (string) $order->getOrderCurrencyCode(); + + foreach ($this->creditmemosOf($order) as $creditmemo) { + $occurredAt = $this->timestamp->toRfc3339($creditmemo->getCreatedAt()); + + if ($occurredAt === null) { + continue; + } + + /** @var AdjustmentInterface $adjustment */ + $adjustment = $this->adjustmentFactory->create(); + $adjustment->setId($this->documentId($creditmemo->getIncrementId(), $creditmemo->getEntityId())); + $adjustment->setType(self::TYPE_REFUND); + $adjustment->setOccurredAt($occurredAt); + $adjustment->setStatus(AdjustmentInterface::STATUS_COMPLETED); + + $lineItems = $this->refundedLineItems($creditmemo, $lineItemIds); + + if ($lineItems !== []) { + $adjustment->setLineItems($lineItems); + } + + // Negative: the spec reads the sign as direction, and this money went back to the buyer. + $adjustment->setTotals([ + $this->total( + TotalTypeInterface::TYPE_TOTAL, + 'Refund', + -abs((float) $creditmemo->getGrandTotal()), + $currencyCode + ), + ]); + + $adjustments[] = $adjustment; + } + + return $adjustments; + } + + /** + * Magento records a cancellation on the order itself rather than as a document, so there is no + * timestamp for it beyond when the order last changed. + * + * @param Order $order + * @return AdjustmentInterface|null + */ + public function convertCancellation(Order $order): ?AdjustmentInterface + { + $occurredAt = $this->timestamp->toRfc3339($order->getUpdatedAt()); + + if (!$order->isCanceled() || $occurredAt === null) { + return null; + } + + /** @var AdjustmentInterface $adjustment */ + $adjustment = $this->adjustmentFactory->create(); + $adjustment->setId('cancellation-' . (string) $order->getIncrementId()); + $adjustment->setType(self::TYPE_CANCELLATION); + $adjustment->setOccurredAt($occurredAt); + $adjustment->setStatus(AdjustmentInterface::STATUS_COMPLETED); + + return $adjustment; + } + + /** + * A sales document is identified by its increment id; the row id stands in only for one somehow + * saved without one. + * + * @param mixed $incrementId + * @param mixed $entityId + * @return string + */ + private function documentId(mixed $incrementId, mixed $entityId): string + { + if (is_scalar($incrementId) && (string) $incrementId !== '') { + return (string) $incrementId; + } + + return is_numeric($entityId) ? (string) (int) $entityId : ''; + } + + /** + * Read through the repository rather than through the order's own collection, which an order instance + * loaded before the memo existed caches empty. + * + * @param Order $order + * @return Creditmemo[] + */ + private function creditmemosOf(Order $order): array + { + $entityId = $order->getEntityId(); + + if (!is_numeric($entityId)) { + return []; + } + + $criteria = $this->searchCriteriaBuilder + ->addFilter(CreditmemoInterface::ORDER_ID, (int) $entityId) + ->create(); + + $creditmemos = []; + + foreach ($this->creditmemoRepository->getList($criteria)->getItems() as $creditmemo) { + if ($creditmemo instanceof Creditmemo) { + $creditmemos[] = $creditmemo; + } + } + + return $creditmemos; + } + + /** + * @param Creditmemo $creditmemo + * @param array $lineItemIds + * @return AdjustmentLineItemsItemInterface[] + */ + private function refundedLineItems(Creditmemo $creditmemo, array $lineItemIds): array + { + $lineItems = []; + + foreach ($creditmemo->getItems() as $creditmemoItem) { + $orderItemId = (int) $creditmemoItem->getOrderItemId(); + $quantity = (int) round((float) $creditmemoItem->getQty()); + + if ($quantity < 1 || !isset($lineItemIds[$orderItemId])) { + continue; + } + + /** @var AdjustmentLineItemsItemInterface $lineItem */ + $lineItem = $this->lineItemFactory->create(); + $lineItem->setId($lineItemIds[$orderItemId]); + // Negative because the quantity left the order, as the schema spells out for returns. + $lineItem->setQuantity(-$quantity); + + $lineItems[] = $lineItem; + } + + return $lineItems; + } + + /** + * @param string $type + * @param string $displayText + * @param float $amount + * @param string $currencyCode + * @return TotalResponseInterface + */ + private function total( + string $type, + string $displayText, + float $amount, + string $currencyCode + ): TotalResponseInterface { + /** @var TotalResponseInterface $total */ + $total = $this->totalFactory->create(); + $total->setType($type); + $total->setDisplayText($displayText); + $total->setAmount($this->minorUnits->convert($amount, $currencyCode)); + + return $total; + } +} diff --git a/Model/Service/Shopping/Converter/OrderToFulfillment.php b/Model/Service/Shopping/Converter/OrderToFulfillment.php new file mode 100644 index 0000000..6bd468e --- /dev/null +++ b/Model/Service/Shopping/Converter/OrderToFulfillment.php @@ -0,0 +1,345 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\UcpSpec\Api\Shopping\OrderResponseFulfillmentInterface; +use Magebit\UcpSpec\Api\Shopping\OrderResponseFulfillmentInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\ExpectationInterface; +use Magebit\UcpSpec\Api\Shopping\Types\ExpectationInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\ExpectationLineItemsItemInterface; +use Magebit\UcpSpec\Api\Shopping\Types\ExpectationLineItemsItemInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentEventInterface; +use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentEventInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentEventLineItemsItemInterface; +use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentEventLineItemsItemInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\PostalAddressInterface; +use Magebit\UniversalCommerce\Model\Timestamp; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Sales\Api\Data\ShipmentInterface; +use Magento\Sales\Api\ShipmentRepositoryInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Shipment; +use Magento\Sales\Model\Order\Shipment\Track; +use Magento\Shipping\Helper\Data as ShippingHelper; + +/** + * The order's fulfillment: what the buyer was told to expect, and the append-only log of what actually + * shipped. The two are separate on purpose — an expectation can be revised, an event cannot. + */ +class OrderToFulfillment +{ + /** + * The order is one buyer-facing group, matching the single package the checkout quoted. + */ + public const EXPECTATION_ID = 'package'; + + /** + * `fulfillment_event.type` is an open string; this is the spec's documented value for goods handed + * to a carrier, which is what a Magento shipment records. + */ + public const EVENT_TYPE_SHIPPED = 'shipped'; + + /** + * Carrier codes that mean the buyer collects the goods rather than having them delivered. + */ + private const PICKUP_CARRIERS = ['instore', 'instore_pickup', 'pickup']; + + /** + * @param OrderResponseFulfillmentInterfaceFactory $fulfillmentFactory + * @param ExpectationInterfaceFactory $expectationFactory + * @param ExpectationLineItemsItemInterfaceFactory $expectationLineItemFactory + * @param FulfillmentEventInterfaceFactory $eventFactory + * @param FulfillmentEventLineItemsItemInterfaceFactory $eventLineItemFactory + * @param OrderAddressToPostalAddress $addressConverter + * @param ShippingHelper $shippingHelper + * @param Timestamp $timestamp + * @param ShipmentRepositoryInterface $shipmentRepository + * @param SearchCriteriaBuilder $searchCriteriaBuilder + */ + public function __construct( + private readonly OrderResponseFulfillmentInterfaceFactory $fulfillmentFactory, + private readonly ExpectationInterfaceFactory $expectationFactory, + private readonly ExpectationLineItemsItemInterfaceFactory $expectationLineItemFactory, + private readonly FulfillmentEventInterfaceFactory $eventFactory, + private readonly FulfillmentEventLineItemsItemInterfaceFactory $eventLineItemFactory, + private readonly OrderAddressToPostalAddress $addressConverter, + private readonly ShippingHelper $shippingHelper, + private readonly Timestamp $timestamp, + private readonly ShipmentRepositoryInterface $shipmentRepository, + private readonly SearchCriteriaBuilder $searchCriteriaBuilder + ) { + } + + /** + * @param Order $order + * @param array $lineItemIds Order item id to the identifier the response exposes + * @param array $orderedQuantities Order item id to the quantity the buyer bought + * @return OrderResponseFulfillmentInterface + */ + public function convert( + Order $order, + array $lineItemIds, + array $orderedQuantities + ): OrderResponseFulfillmentInterface { + /** @var OrderResponseFulfillmentInterface $fulfillment */ + $fulfillment = $this->fulfillmentFactory->create(); + + $expectation = $this->convertExpectation($order, $lineItemIds, $orderedQuantities); + + if ($expectation !== null) { + $fulfillment->setExpectations([$expectation]); + } + + $fulfillment->setEvents($this->convertEvents($order, $lineItemIds)); + + return $fulfillment; + } + + /** + * @param Order $order + * @param array $lineItemIds + * @param array $orderedQuantities + * @return ExpectationInterface|null + */ + public function convertExpectation( + Order $order, + array $lineItemIds, + array $orderedQuantities + ): ?ExpectationInterface { + $destination = $this->destinationOf($order); + $lineItems = []; + + foreach ($orderedQuantities as $orderItemId => $quantity) { + if ($quantity < 1 || !isset($lineItemIds[$orderItemId])) { + continue; + } + + /** @var ExpectationLineItemsItemInterface $lineItem */ + $lineItem = $this->expectationLineItemFactory->create(); + $lineItem->setId($lineItemIds[$orderItemId]); + $lineItem->setQuantity($quantity); + + $lineItems[] = $lineItem; + } + + if ($destination === null || $lineItems === []) { + return null; + } + + /** @var ExpectationInterface $expectation */ + $expectation = $this->expectationFactory->create(); + $expectation->setId(self::EXPECTATION_ID); + $expectation->setLineItems($lineItems); + $expectation->setMethodType($this->methodTypeOf($order)); + $expectation->setDestination($destination); + + $description = $order->getShippingDescription(); + + if (is_string($description) && $description !== '') { + $expectation->setDescription($description); + } + + return $expectation; + } + + /** + * Each shipment is one event. Magento allows several parcels per shipment, so an event reports the + * first track's number and links to the shipment's tracking page, which lists all of them. + * + * @param Order $order + * @param array $lineItemIds + * @return FulfillmentEventInterface[] + */ + public function convertEvents(Order $order, array $lineItemIds): array + { + $events = []; + + foreach ($this->shipmentsOf($order) as $shipment) { + + $lineItems = $this->eventLineItems($shipment, $lineItemIds); + $occurredAt = $this->timestamp->toRfc3339($shipment->getCreatedAt()); + + // An event with no time is not an event: the log is ordered by when things happened. + if ($lineItems === [] || $occurredAt === null) { + continue; + } + + /** @var FulfillmentEventInterface $event */ + $event = $this->eventFactory->create(); + $event->setId($this->documentId($shipment->getIncrementId(), $shipment->getEntityId())); + $event->setOccurredAt($occurredAt); + // Reported as shipped even with no track recorded: the goods left, and `processing` would + // tell the agent nothing had. The spec expects tracking on a shipped event; Magento does + // not require it. + $event->setType(self::EVENT_TYPE_SHIPPED); + $event->setLineItems($lineItems); + + $this->describeTracking($event, $shipment); + + $events[] = $event; + } + + return $events; + } + + /** + * A sales document is identified by its increment id; the row id stands in only for one somehow + * saved without one. + * + * @param mixed $incrementId + * @param mixed $entityId + * @return string + */ + private function documentId(mixed $incrementId, mixed $entityId): string + { + if (is_scalar($incrementId) && (string) $incrementId !== '') { + return (string) $incrementId; + } + + return is_numeric($entityId) ? (string) (int) $entityId : ''; + } + + /** + * Read through the repository rather than through the order's own collection: an order instance that + * was loaded before a shipment existed caches the empty result, and an observer is handed exactly + * such an instance. + * + * @param Order $order + * @return Shipment[] + */ + private function shipmentsOf(Order $order): array + { + $entityId = $order->getEntityId(); + + if (!is_numeric($entityId)) { + return []; + } + + $criteria = $this->searchCriteriaBuilder + ->addFilter(ShipmentInterface::ORDER_ID, (int) $entityId) + ->create(); + + $shipments = []; + + foreach ($this->shipmentRepository->getList($criteria)->getItems() as $shipment) { + if ($shipment instanceof Shipment) { + $shipments[] = $shipment; + } + } + + return $shipments; + } + + /** + * @param Shipment $shipment + * @param array $lineItemIds + * @return FulfillmentEventLineItemsItemInterface[] + */ + private function eventLineItems(Shipment $shipment, array $lineItemIds): array + { + $lineItems = []; + + foreach ($shipment->getItems() as $shipmentItem) { + $orderItemId = (int) $shipmentItem->getOrderItemId(); + $quantity = (int) round((float) $shipmentItem->getQty()); + + if ($quantity < 1 || !isset($lineItemIds[$orderItemId])) { + continue; + } + + /** @var FulfillmentEventLineItemsItemInterface $lineItem */ + $lineItem = $this->eventLineItemFactory->create(); + $lineItem->setId($lineItemIds[$orderItemId]); + $lineItem->setQuantity($quantity); + + $lineItems[] = $lineItem; + } + + return $lineItems; + } + + /** + * @param FulfillmentEventInterface $event + * @param Shipment $shipment + * @return void + */ + private function describeTracking(FulfillmentEventInterface $event, Shipment $shipment): void + { + $track = null; + + foreach ($shipment->getAllTracks() as $candidate) { + if ($candidate instanceof Track) { + $track = $candidate; + break; + } + } + + if ($track === null) { + return; + } + + $trackNumber = (string) $track->getTrackNumber(); + + if ($trackNumber !== '') { + $event->setTrackingNumber($trackNumber); + } + + $carrier = (string) ($track->getTitle() ?: $track->getCarrierCode()); + + if ($carrier !== '') { + $event->setCarrier($carrier); + } + + // The hashed popup URL is the only tracking page Magento exposes without knowing the + // carrier's own URL scheme, and the helper needs the concrete model to build it. + $url = (string) $this->shippingHelper->getTrackingPopupUrlBySalesModel($shipment); + + if ($url !== '') { + $event->setTrackingUrl($url); + } + } + + /** + * @param Order $order + * @return string + */ + private function methodTypeOf(Order $order): string + { + if ($order->getIsVirtual()) { + return ExpectationInterface::METHOD_TYPE_DIGITAL; + } + + // getShippingMethod() can hand back a parsed object instead of the stored string. + $shippingMethod = $order->getShippingMethod(); + $carrierCode = is_string($shippingMethod) ? explode('_', $shippingMethod)[0] : ''; + + return in_array($carrierCode, self::PICKUP_CARRIERS, true) + ? ExpectationInterface::METHOD_TYPE_PICKUP + : ExpectationInterface::METHOD_TYPE_SHIPPING; + } + + /** + * A virtual order has nowhere to ship to, so the billing address stands in as the destination the + * spec requires on every expectation. + * + * @param Order $order + * @return PostalAddressInterface|null + */ + private function destinationOf(Order $order): ?PostalAddressInterface + { + $address = $order->getIsVirtual() ? $order->getBillingAddress() : $order->getShippingAddress(); + $address ??= $order->getBillingAddress(); + + return $address === null ? null : $this->addressConverter->convert($address); + } +} diff --git a/Model/Service/Shopping/Converter/OrderToOrderResponse.php b/Model/Service/Shopping/Converter/OrderToOrderResponse.php new file mode 100644 index 0000000..802dd48 --- /dev/null +++ b/Model/Service/Shopping/Converter/OrderToOrderResponse.php @@ -0,0 +1,207 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\UcpSpec\Api\Shopping\OrderResponseInterface; +use Magebit\UcpSpec\Api\Shopping\OrderResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemInterface; +use Magebit\UcpSpec\Api\UcpResponseOrderSchemaInterface; +use Magebit\UcpSpec\Api\UcpResponseOrderSchemaInterfaceFactory; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\Discovery\ServiceRegistry; +use Magento\Framework\Exception\LocalizedException; +use Magento\Sales\Api\Data\OrderItemInterface; +use Magento\Sales\Model\Order; + +/** + * Builds the order representation the Order capability serves and the order webhooks carry. + */ +class OrderToOrderResponse +{ + /** + * Path the storefront serves an order on, which is what `permalink_url` points at. + */ + private const PERMALINK_PATH = '/sales/order/view/order_id/'; + + /** + * @param OrderResponseInterfaceFactory $orderResponseFactory + * @param UcpResponseOrderSchemaInterfaceFactory $ucpResponseFactory + * @param OrderItemToOrderLineItem $lineItemConverter + * @param OrderToTotalsResponse $totalsConverter + * @param OrderToFulfillment $fulfillmentConverter + * @param OrderToAdjustments $adjustmentsConverter + * @param ServiceRegistry $serviceRegistry + * @param Config $config + */ + public function __construct( + private readonly OrderResponseInterfaceFactory $orderResponseFactory, + private readonly UcpResponseOrderSchemaInterfaceFactory $ucpResponseFactory, + private readonly OrderItemToOrderLineItem $lineItemConverter, + private readonly OrderToTotalsResponse $totalsConverter, + private readonly OrderToFulfillment $fulfillmentConverter, + private readonly OrderToAdjustments $adjustmentsConverter, + private readonly ServiceRegistry $serviceRegistry, + private readonly Config $config + ) { + } + + /** + * @param Order $order + * @param string $checkoutId Session the order was placed from + * @return OrderResponseInterface + * @throws LocalizedException When the shopping service is not registered + */ + public function convert(Order $order, string $checkoutId): OrderResponseInterface + { + $currencyCode = (string) $order->getOrderCurrencyCode(); + $items = $this->itemsOf($order); + $lineItemIds = $this->lineItemIds($items); + + /** @var OrderResponseInterface $response */ + $response = $this->orderResponseFactory->create(); + $response->setUcp($this->getUcp()); + // The order is addressed by its checkout session, which nobody can guess. The store's own + // order number is only a label, because it runs in sequence and anyone could count up to it. + $response->setId($checkoutId); + $response->setLabel((string) $order->getIncrementId()); + $response->setCheckoutId($checkoutId); + $response->setPermalinkUrl($this->permalinkUrl($order)); + $response->setLineItems($this->getLineItems($items, $lineItemIds, $currencyCode)); + $response->setFulfillment( + $this->fulfillmentConverter->convert($order, $lineItemIds, $this->orderedQuantities($items)) + ); + $response->setCurrency($currencyCode); + $response->setTotals($this->totalsConverter->convert($order)); + + $adjustments = $this->adjustmentsConverter->convert($order, $lineItemIds); + + if ($adjustments !== []) { + $response->setAdjustments($adjustments); + } + + return $response; + } + + /** + * @return UcpResponseOrderSchemaInterface + * @throws LocalizedException + */ + public function getUcp(): UcpResponseOrderSchemaInterface + { + $service = $this->serviceRegistry->getService('dev.ucp.shopping'); + + if (!$service) { + throw new LocalizedException(__('Shopping service not registered')); + } + + // No payment handlers: the order is already paid for, and the schema says as much. + return $this->ucpResponseFactory->create([ + 'data' => [ + UcpResponseOrderSchemaInterface::KEY_VERSION => UniversalCommerceProtocolInterface::SPEC_VERSION, + UcpResponseOrderSchemaInterface::KEY_STATUS => UcpResponseOrderSchemaInterface::STATUS_SUCCESS, + UcpResponseOrderSchemaInterface::KEY_CAPABILITIES => $service->getCapabilities(), + ] + ]); + } + + /** + * @param OrderItemInterface[] $items + * @param array $lineItemIds + * @param string $currencyCode + * @return OrderLineItemInterface[] + */ + public function getLineItems(array $items, array $lineItemIds, string $currencyCode): array + { + $lineItems = []; + // One product load for the whole order, rather than one for every line. + $imageUrls = $this->lineItemConverter->imageUrls($items); + + foreach ($items as $item) { + $itemId = (int) $item->getItemId(); + $parentItemId = $item->getParentItemId(); + $parentId = $parentItemId === null ? null : ($lineItemIds[(int) $parentItemId] ?? null); + + $lineItems[] = $this->lineItemConverter->convert( + $item, + $currencyCode, + $lineItemIds[$itemId] ?? (string) $itemId, + $parentId, + $imageUrls[(int) $item->getProductId()] ?? null + ); + } + + return $lineItems; + } + + /** + * The line item keeps the identifier the checkout already gave the agent, so the two responses can + * be reconciled against each other; an item added after the order has no such identifier and falls + * back to its own. + * + * @param OrderItemInterface[] $items + * @return array Order item id to the identifier the response exposes + */ + public function lineItemIds(array $items): array + { + $ids = []; + + foreach ($items as $item) { + $itemId = (int) $item->getItemId(); + $quoteItemId = $item->getQuoteItemId(); + + $ids[$itemId] = $quoteItemId === null ? (string) $itemId : (string) $quoteItemId; + } + + return $ids; + } + + /** + * @param OrderItemInterface[] $items + * @return array Order item id to the quantity the buyer bought + */ + private function orderedQuantities(array $items): array + { + $quantities = []; + + foreach ($items as $item) { + $quantities[(int) $item->getItemId()] = (int) round((float) $item->getQtyOrdered()); + } + + return $quantities; + } + + /** + * Includes the children of composite products, which carry `parent_id` — the same set the checkout + * response lists, so line items do not appear or vanish when the order is placed. + * + * @param Order $order + * @return OrderItemInterface[] + */ + private function itemsOf(Order $order): array + { + return array_values($order->getAllItems()); + } + + /** + * @param Order $order + * @return string + */ + private function permalinkUrl(Order $order): string + { + $entityId = $order->getEntityId(); + + return $this->config->getApiBaseUrl((int) $order->getStoreId()) + . self::PERMALINK_PATH . (is_numeric($entityId) ? (string) (int) $entityId : ''); + } +} diff --git a/Model/Service/Shopping/Converter/OrderToTotalsResponse.php b/Model/Service/Shopping/Converter/OrderToTotalsResponse.php new file mode 100644 index 0000000..b42b170 --- /dev/null +++ b/Model/Service/Shopping/Converter/OrderToTotalsResponse.php @@ -0,0 +1,110 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; +use Magento\Sales\Api\Data\OrderInterface; + +/** + * The order's own totals. Unlike a quote, an order stores its breakdown in fixed columns rather than + * in collector rows, so nothing here needs the code-to-type mapping the checkout's converter takes. + */ +class OrderToTotalsResponse +{ + /** + * @param TotalResponseInterfaceFactory $totalFactory + * @param MinorUnits $minorUnits + */ + public function __construct( + private readonly TotalResponseInterfaceFactory $totalFactory, + private readonly MinorUnits $minorUnits + ) { + } + + /** + * Shipping is taken excluding tax and the tax entry carries all of it, so the entries sum to the + * grand total instead of counting shipping tax twice. + * + * @param OrderInterface $order + * @return TotalResponseInterface[] + */ + public function convert(OrderInterface $order): array + { + $currencyCode = (string) $order->getOrderCurrencyCode(); + $discount = abs((float) $order->getDiscountAmount()); + + $totals = [ + $this->total(TotalTypeInterface::TYPE_SUBTOTAL, 'Subtotal', (float) $order->getSubtotal(), $currencyCode), + ]; + + if ($discount > 0) { + $totals[] = $this->total( + TotalTypeInterface::TYPE_DISCOUNT, + (string) ($order->getDiscountDescription() ?: 'Discount'), + -$discount, + $currencyCode + ); + } + + $shipping = (float) $order->getShippingAmount(); + + if ($shipping > 0) { + $totals[] = $this->total( + TotalTypeInterface::TYPE_FULFILLMENT, + (string) ($order->getShippingDescription() ?: 'Shipping'), + $shipping, + $currencyCode + ); + } + + $tax = (float) $order->getTaxAmount(); + + if ($tax > 0) { + $totals[] = $this->total(TotalTypeInterface::TYPE_TAX, 'Tax', $tax, $currencyCode); + } + + $totals[] = $this->total( + TotalTypeInterface::TYPE_TOTAL, + 'Grand Total', + (float) $order->getGrandTotal(), + $currencyCode + ); + + return $totals; + } + + /** + * @param string $type + * @param string $displayText + * @param float $amount + * @param string $currencyCode + * @return TotalResponseInterface + */ + private function total( + string $type, + string $displayText, + float $amount, + string $currencyCode + ): TotalResponseInterface { + /** @var TotalResponseInterface $total */ + $total = $this->totalFactory->create(); + $total->setType($type); + $total->setDisplayText($displayText); + $total->setAmount($this->minorUnits->convert($amount, $currencyCode)); + + return $total; + } +} diff --git a/Model/Service/Shopping/Converter/PriceConverter.php b/Model/Service/Shopping/Converter/PriceConverter.php deleted file mode 100644 index a4c2b71..0000000 --- a/Model/Service/Shopping/Converter/PriceConverter.php +++ /dev/null @@ -1,76 +0,0 @@ - - * @license MIT - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; - -class PriceConverter -{ - public const DEFAULT_EXPONENT = 2; - - /** - * ISO 4217 currencies whose minor-unit exponent differs from the default of 2. - * - * @var array - */ - private const EXPONENTS = [ - 'BIF' => 0, - 'CLP' => 0, - 'DJF' => 0, - 'GNF' => 0, - 'ISK' => 0, - 'JPY' => 0, - 'KMF' => 0, - 'KRW' => 0, - 'PYG' => 0, - 'RWF' => 0, - 'UGX' => 0, - 'UYI' => 0, - 'VND' => 0, - 'VUV' => 0, - 'XAF' => 0, - 'XOF' => 0, - 'XPF' => 0, - 'BHD' => 3, - 'IQD' => 3, - 'JOD' => 3, - 'KWD' => 3, - 'LYD' => 3, - 'OMR' => 3, - 'TND' => 3, - ]; - - /** - * Convert a major-unit amount to the currency's ISO 4217 minor units. - * - * @param float $amount - * @param string $currencyCode - * @return int - */ - public function convert(float $amount, string $currencyCode): int - { - // Round on the decimal string: 19.99 * 100 is 1998.9999999999998, which (int) truncates to 1998. - $decimal = number_format($amount, $this->getExponent($currencyCode), '.', ''); - - return (int) str_replace('.', '', $decimal); - } - - /** - * Number of decimal places the currency's minor unit is expressed in. - * - * @param string $currencyCode - * @return int - */ - public function getExponent(string $currencyCode): int - { - return self::EXPONENTS[strtoupper($currencyCode)] ?? self::DEFAULT_EXPONENT; - } -} diff --git a/Model/Service/Shopping/Converter/ProductToUcpProduct.php b/Model/Service/Shopping/Converter/ProductToUcpProduct.php new file mode 100644 index 0000000..d03439e --- /dev/null +++ b/Model/Service/Shopping/Converter/ProductToUcpProduct.php @@ -0,0 +1,257 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\DescriptionInterface; +use Magebit\UcpSpec\Api\Shopping\Types\DescriptionInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\MediaInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MediaInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\PriceInterface; +use Magebit\UcpSpec\Api\Shopping\Types\PriceInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\PriceRangeInterface; +use Magebit\UcpSpec\Api\Shopping\Types\PriceRangeInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\ProductInterface; +use Magebit\UcpSpec\Api\Shopping\Types\ProductInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\VariantAvailabilityInterface; +use Magebit\UcpSpec\Api\Shopping\Types\VariantAvailabilityInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\VariantInterface; +use Magebit\UcpSpec\Api\Shopping\Types\VariantInterfaceFactory; +use Magebit\AgenticCore\Model\Stock\Availability; +use Magento\Catalog\Model\Product as MagentoProduct; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; + +/** + * Builds the spec's catalog product from a Magento one. An agent buys a variant, so a product with no + * children is reported as its own single variant. + */ +class ProductToUcpProduct +{ + public const STATUS_IN_STOCK = 'in_stock'; + public const STATUS_OUT_OF_STOCK = 'out_of_stock'; + + private const MEDIA_TYPE_IMAGE = 'image'; + + /** + * @param ProductInterfaceFactory $productFactory + * @param VariantInterfaceFactory $variantFactory + * @param PriceInterfaceFactory $priceFactory + * @param PriceRangeInterfaceFactory $priceRangeFactory + * @param DescriptionInterfaceFactory $descriptionFactory + * @param MediaInterfaceFactory $mediaFactory + * @param VariantAvailabilityInterfaceFactory $availabilityFactory + * @param MinorUnits $minorUnits + * @param Availability $stock + */ + public function __construct( + private readonly ProductInterfaceFactory $productFactory, + private readonly VariantInterfaceFactory $variantFactory, + private readonly PriceInterfaceFactory $priceFactory, + private readonly PriceRangeInterfaceFactory $priceRangeFactory, + private readonly DescriptionInterfaceFactory $descriptionFactory, + private readonly MediaInterfaceFactory $mediaFactory, + private readonly VariantAvailabilityInterfaceFactory $availabilityFactory, + private readonly MinorUnits $minorUnits, + private readonly Availability $stock + ) { + } + + /** + * @param MagentoProduct $product + * @param string $currencyCode + * @param MagentoProduct[] $children Variants, when the product has any + * @param string|null $url + * @param string|null $imageUrl + * @return ProductInterface + */ + public function convert( + MagentoProduct $product, + string $currencyCode, + array $children = [], + ?string $url = null, + ?string $imageUrl = null + ): ProductInterface { + $variants = $this->variantsFor($product, $currencyCode, $children); + + /** @var ProductInterface $result */ + $result = $this->productFactory->create(); + $result->setId((string) $product->getSku()); + $result->setTitle((string) $product->getName()); + $result->setDescription($this->descriptionFor($product)); + $result->setPriceRange($this->rangeOver($variants, $currencyCode)); + $result->setVariants($variants); + + if ($url !== null) { + $result->setUrl($url); + } + + if ($imageUrl !== null) { + $result->setMedia([$this->mediaFor($imageUrl)]); + } + + return $result; + } + + /** + * @param MagentoProduct $product + * @param string $currencyCode + * @param MagentoProduct[] $children + * @return VariantInterface[] + */ + private function variantsFor(MagentoProduct $product, string $currencyCode, array $children): array + { + // Configurable products are bought through a child; everything else is bought as itself, and the + // spec requires at least one variant either way. + $sources = $children !== [] || $product->getTypeId() === Configurable::TYPE_CODE + ? $children + : [$product]; + + $variants = []; + + foreach ($sources as $source) { + $variants[] = $this->variantFor($source, $currencyCode); + } + + return $variants; + } + + /** + * @param MagentoProduct $product + * @param string $currencyCode + * @return VariantInterface + */ + private function variantFor(MagentoProduct $product, string $currencyCode): VariantInterface + { + /** @var VariantInterface $variant */ + $variant = $this->variantFactory->create(); + $variant->setId((string) $product->getSku()); + $variant->setSku((string) $product->getSku()); + $variant->setTitle((string) $product->getName()); + $variant->setDescription($this->descriptionFor($product)); + $variant->setPrice($this->priceFor($this->finalPriceOf($product), $currencyCode)); + $variant->setAvailability($this->availabilityFor($product)); + + return $variant; + } + + /** + * @param MagentoProduct $product + * @return VariantAvailabilityInterface + */ + private function availabilityFor(MagentoProduct $product): VariantAvailabilityInterface + { + // A product read as part of a page already carries its stock, read for the whole page at once. + // Only one read on its own has to ask. + $loaded = $product->getData('is_salable'); + // Read from the database it arrives as text, so "0" has to mean out of stock. + $available = is_scalar($loaded) + ? (int) $loaded === 1 + : $this->stock->isSalable((string) $product->getSku()); + + /** @var VariantAvailabilityInterface $availability */ + $availability = $this->availabilityFactory->create(); + $availability->setAvailable($available); + $availability->setStatus($available ? self::STATUS_IN_STOCK : self::STATUS_OUT_OF_STOCK); + + return $availability; + } + + /** + * @param VariantInterface[] $variants + * @param string $currencyCode + * @return PriceRangeInterface + */ + private function rangeOver(array $variants, string $currencyCode): PriceRangeInterface + { + $amounts = []; + + foreach ($variants as $variant) { + $amounts[] = $variant->getPrice()->getAmount(); + } + + $amounts = $amounts === [] ? [0] : $amounts; + + /** @var PriceRangeInterface $range */ + $range = $this->priceRangeFactory->create(); + $range->setMin($this->priceFromMinorUnits(min($amounts), $currencyCode)); + $range->setMax($this->priceFromMinorUnits(max($amounts), $currencyCode)); + + return $range; + } + + /** + * @param MagentoProduct $product + * @return float + */ + private function finalPriceOf(MagentoProduct $product): float + { + return (float) $product->getFinalPrice(); + } + + /** + * @param float $amount + * @param string $currencyCode + * @return PriceInterface + */ + private function priceFor(float $amount, string $currencyCode): PriceInterface + { + return $this->priceFromMinorUnits($this->minorUnits->convert($amount, $currencyCode), $currencyCode); + } + + /** + * @param int $minorUnits + * @param string $currencyCode + * @return PriceInterface + */ + private function priceFromMinorUnits(int $minorUnits, string $currencyCode): PriceInterface + { + /** @var PriceInterface $price */ + $price = $this->priceFactory->create(); + $price->setAmount($minorUnits); + $price->setCurrency($currencyCode); + + return $price; + } + + /** + * The field is required, so a product without one reports an empty description rather than omitting it. + * + * @param MagentoProduct $product + * @return DescriptionInterface + */ + private function descriptionFor(MagentoProduct $product): DescriptionInterface + { + $raw = $product->getData('description'); + $plain = is_string($raw) ? trim(strip_tags($raw)) : ''; + + /** @var DescriptionInterface $description */ + $description = $this->descriptionFactory->create(); + $description->setPlain($plain); + + return $description; + } + + /** + * @param string $url + * @return MediaInterface + */ + private function mediaFor(string $url): MediaInterface + { + /** @var MediaInterface $media */ + $media = $this->mediaFactory->create(); + $media->setType(self::MEDIA_TYPE_IMAGE); + $media->setUrl($url); + + return $media; + } +} diff --git a/Model/Service/Shopping/Converter/QuoteItemToLineItemResponse.php b/Model/Service/Shopping/Converter/QuoteItemToLineItemResponse.php index 0c2f4b5..4b7a300 100644 --- a/Model/Service/Shopping/Converter/QuoteItemToLineItemResponse.php +++ b/Model/Service/Shopping/Converter/QuoteItemToLineItemResponse.php @@ -12,9 +12,11 @@ namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; +use Magebit\AgenticCore\Model\Money\MinorUnits; use Magebit\UcpSpec\Api\Shopping\Types\LineItemResponseInterface; use Magebit\UcpSpec\Api\Shopping\Types\ItemResponseInterface; use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; use Magebit\UcpSpec\Api\Shopping\Types\LineItemResponseInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\Types\ItemResponseInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; @@ -29,7 +31,7 @@ public function __construct( protected readonly ItemResponseInterfaceFactory $itemResponseFactory, protected readonly TotalResponseInterfaceFactory $totalResponseFactory, protected readonly ImageHelper $imageHelper, - protected readonly PriceConverter $priceConverter + protected readonly MinorUnits $minorUnits ) { } @@ -62,7 +64,7 @@ public function convertItem(CartItemInterface $quoteItem): ItemResponseInterface $item = $this->itemResponseFactory->create(); $item->setId($product->getSku()); $item->setTitle($product->getName()); - $item->setPrice($this->priceConverter->convert( + $item->setPrice($this->minorUnits->convert( (float) $quoteItem->getPrice(), $this->getCurrencyCode($quoteItem) )); @@ -92,18 +94,19 @@ public function convertTotals(CartItemInterface $quoteItem): array $subtotal = (float) $quoteItem->getRowTotal(); if ($subtotal > 0) { $total = $this->totalResponseFactory->create(); - $total->setType(TotalResponseInterface::TYPE_SUBTOTAL); - $total->setAmount($this->priceConverter->convert($subtotal, $currencyCode)); + $total->setType(TotalTypeInterface::TYPE_SUBTOTAL); + $total->setAmount($this->minorUnits->convert($subtotal, $currencyCode)); $total->setDisplayText('Subtotal'); $totals[] = $total; } - // Discount (item-level discount) - $discountAmount = (float) $quoteItem->getDiscountAmount(); + // Item-level discount, emitted negative: the spec constrains `items_discount` to + // `exclusiveMaximum: 0`, so the sign is part of the value rather than presentation. + $discountAmount = abs((float) $quoteItem->getDiscountAmount()); if ($discountAmount > 0) { $total = $this->totalResponseFactory->create(); - $total->setType(TotalResponseInterface::TYPE_ITEMS_DISCOUNT); - $total->setAmount($this->priceConverter->convert($discountAmount, $currencyCode)); + $total->setType(TotalTypeInterface::TYPE_ITEMS_DISCOUNT); + $total->setAmount($this->minorUnits->convert(-$discountAmount, $currencyCode)); $total->setDisplayText('Discount'); $totals[] = $total; } @@ -111,8 +114,8 @@ public function convertTotals(CartItemInterface $quoteItem): array // Total (including tax) $rowTotal = (float) $quoteItem->getRowTotalInclTax(); $total = $this->totalResponseFactory->create(); - $total->setType(TotalResponseInterface::TYPE_TOTAL); - $total->setAmount($this->priceConverter->convert($rowTotal, $currencyCode)); + $total->setType(TotalTypeInterface::TYPE_TOTAL); + $total->setAmount($this->minorUnits->convert($rowTotal, $currencyCode)); $total->setDisplayText('Total'); $totals[] = $total; diff --git a/Model/Service/Shopping/Converter/QuoteToBuyerResponse.php b/Model/Service/Shopping/Converter/QuoteToBuyerResponse.php index 7213484..334c6d1 100644 --- a/Model/Service/Shopping/Converter/QuoteToBuyerResponse.php +++ b/Model/Service/Shopping/Converter/QuoteToBuyerResponse.php @@ -12,8 +12,12 @@ namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; +use Magebit\AgenticCore\Model\Buyer\BuyerResolver; +use Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseConsentInterface; +use Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseConsentInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\Types\BuyerInterface; use Magebit\UcpSpec\Api\Shopping\Types\BuyerInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface; use Magento\Quote\Api\Data\CartInterface; use Magento\Quote\Model\Quote; @@ -21,48 +25,55 @@ class QuoteToBuyerResponse { /** * @param BuyerInterfaceFactory $buyerInterfaceFactory + * @param BuyerResolver $buyerResolver + * @param BuyerConsentResponseConsentInterfaceFactory $consentFactory */ public function __construct( protected readonly BuyerInterfaceFactory $buyerInterfaceFactory, + protected readonly BuyerResolver $buyerResolver, + protected readonly BuyerConsentResponseConsentInterfaceFactory $consentFactory ) { } /** * @param CartInterface $quote + * @param array|null $consent Consent the agent recorded for this checkout * @return BuyerInterface|null */ - public function convert(CartInterface $quote): ?BuyerInterface + public function convert(CartInterface $quote, ?array $consent = null): ?BuyerInterface { /** @var Quote $quote */ - $billingAddress = $quote->getBillingAddress(); + $identity = $this->buyerResolver->resolve($quote); - $firstName = $quote->getCustomerFirstname() ?: $billingAddress->getFirstname(); - $lastName = $quote->getCustomerLastname() ?: $billingAddress->getLastname(); - $email = $quote->getCustomerEmail() ?: $billingAddress->getEmail(); - $phoneNumber = $billingAddress->getTelephone(); - - // An all-empty buyer is omitted rather than sent as an empty object. - if (!$firstName && !$lastName && !$email && !$phoneNumber) { + // An all-empty buyer is omitted rather than sent as an empty object; recorded consent is a + // buyer fact of its own, so it counts as something to report. + if ($identity->isEmpty() && $consent === null) { return null; } /** @var BuyerInterface $buyer */ $buyer = $this->buyerInterfaceFactory->create(); - if ($firstName) { - $buyer->setFirstName($firstName); + if ($identity->firstName !== null) { + $buyer->setFirstName($identity->firstName); + } + + if ($identity->lastName !== null) { + $buyer->setLastName($identity->lastName); } - if ($lastName) { - $buyer->setLastName($lastName); + if ($identity->email !== null) { + $buyer->setEmail($identity->email); } - if ($email) { - $buyer->setEmail($email); + if ($identity->phoneNumber !== null) { + $buyer->setPhoneNumber($identity->phoneNumber); } - if ($phoneNumber) { - $buyer->setPhoneNumber($phoneNumber); + if ($consent !== null && $buyer instanceof BuyerWithConsentInterface) { + /** @var BuyerConsentResponseConsentInterface $consentObject */ + $consentObject = $this->consentFactory->create(['data' => $consent]); + $buyer->setConsent($consentObject); } return $buyer; diff --git a/Model/Service/Shopping/Converter/QuoteToCartResponse.php b/Model/Service/Shopping/Converter/QuoteToCartResponse.php new file mode 100644 index 0000000..c0352e9 --- /dev/null +++ b/Model/Service/Shopping/Converter/QuoteToCartResponse.php @@ -0,0 +1,103 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; + +use Magebit\UcpSpec\Api\Shopping\CartResponseInterface; +use Magebit\UcpSpec\Api\Shopping\CartResponseInterfaceFactory; +use Magebit\UcpSpec\Api\UcpResponseCartSchemaInterface; +use Magebit\UcpSpec\Api\UcpResponseCartSchemaInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\QuoteValidatorInterface; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; +use Magebit\UniversalCommerce\Model\Discovery\ServiceRegistry; +use Magento\Framework\Exception\LocalizedException; +use Magento\Quote\Api\Data\CartInterface; + +/** + * Builds the cart resource response. A cart is a smaller thing than a checkout, not a checkout with + * fields removed: it has no status, no payment and no fulfillment, and its `ucp` block declares no + * payment handlers. + */ +class QuoteToCartResponse +{ + /** + * @param CartResponseInterfaceFactory $cartResponseFactory + * @param UcpResponseCartSchemaInterfaceFactory $ucpCartFactory + * @param QuoteToCheckoutResponse $checkoutConverter + * @param CartMessageDowngrade $messageDowngrade + * @param QuoteValidatorInterface $quoteValidator + * @param ServiceRegistry $serviceRegistry + */ + public function __construct( + private readonly CartResponseInterfaceFactory $cartResponseFactory, + private readonly UcpResponseCartSchemaInterfaceFactory $ucpCartFactory, + private readonly QuoteToCheckoutResponse $checkoutConverter, + private readonly CartMessageDowngrade $messageDowngrade, + private readonly QuoteValidatorInterface $quoteValidator, + private readonly ServiceRegistry $serviceRegistry + ) { + } + + /** + * @param CartInterface $quote + * @param string $cartId + * @return CartResponseInterface + * @throws LocalizedException + */ + public function convert(CartInterface $quote, string $cartId): CartResponseInterface + { + /** @var CartResponseInterface $response */ + $response = $this->cartResponseFactory->create(); + + $response->setUcp($this->ucpFor()); + $response->setId($cartId); + $response->setLineItems($this->checkoutConverter->getLineItems($quote)); + $response->setCurrency($this->checkoutConverter->getCurrency($quote)); + $response->setTotals($this->checkoutConverter->getTotals($quote)); + $response->setLinks($this->checkoutConverter->getLinks($quote)); + $response->setContinueUrl($this->checkoutConverter->getContinueUrl($cartId)); + + if ($buyer = $this->checkoutConverter->getBuyer($quote)) { + $response->setBuyer($buyer); + } + + if ($expiresAt = $this->checkoutConverter->getExpiresAt($quote)) { + $response->setExpiresAt($expiresAt); + } + + /** @var array $messages */ + $messages = $this->messageDowngrade->apply($this->quoteValidator->validate($quote) ?? []); + $response->setMessages($messages); + + return $response; + } + + /** + * @return UcpResponseCartSchemaInterface + * @throws LocalizedException + */ + private function ucpFor(): UcpResponseCartSchemaInterface + { + $service = $this->serviceRegistry->getService('dev.ucp.shopping'); + + if (!$service) { + throw new LocalizedException(__('Shopping service not registered')); + } + + return $this->ucpCartFactory->create([ + 'data' => [ + UcpResponseCartSchemaInterface::KEY_VERSION => UniversalCommerceProtocolInterface::SPEC_VERSION, + UcpResponseCartSchemaInterface::KEY_CAPABILITIES => $service->getCapabilities(), + ] + ]); + } +} diff --git a/Model/Service/Shopping/Converter/QuoteToCheckoutResponse.php b/Model/Service/Shopping/Converter/QuoteToCheckoutResponse.php index f367c99..4218514 100644 --- a/Model/Service/Shopping/Converter/QuoteToCheckoutResponse.php +++ b/Model/Service/Shopping/Converter/QuoteToCheckoutResponse.php @@ -38,6 +38,10 @@ use Magebit\UcpSpec\Api\Shopping\Types\OrderConfirmationInterface; use Magebit\UcpSpec\Api\Shopping\Types\OrderConfirmationInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\Types\LinkInterfaceFactory; +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\AgenticCore\Model\Checkout\CheckoutState; +use Magebit\AgenticCore\Model\Checkout\StateResolver; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\Framework\Exception\NoSuchEntityException; @@ -59,6 +63,8 @@ class QuoteToCheckoutResponse * @param OrderRepositoryInterface $orderRepository * @param LinkInterfaceFactory $linkFactory * @param Config $config + * @param StateResolver $stateResolver + * @param OrderLinkRepositoryInterface $orderLinkRepository */ public function __construct( protected readonly CheckoutResponseInterfaceFactory $checkoutResponseFactory, @@ -75,7 +81,9 @@ public function __construct( protected readonly OrderConfirmationInterfaceFactory $orderConfirmationFactory, protected readonly OrderRepositoryInterface $orderRepository, protected readonly LinkInterfaceFactory $linkFactory, - protected readonly Config $config + protected readonly Config $config, + protected readonly StateResolver $stateResolver, + protected readonly OrderLinkRepositoryInterface $orderLinkRepository ) { } @@ -92,7 +100,7 @@ public function convert(CartInterface $quote, string $maskedCartId): CheckoutRes $response->setLineItems($this->getLineItems($quote)); - if ($buyer = $this->getBuyer($quote)) { + if ($buyer = $this->getBuyer($quote, $this->getBuyerConsent($maskedCartId))) { $response->setBuyer($buyer); } @@ -181,11 +189,35 @@ public function getLineItems(CartInterface $quote): array /** * @param CartInterface $quote + * @param array|null $consent * @return BuyerInterface|null */ - public function getBuyer(CartInterface $quote): ?BuyerInterface + public function getBuyer(CartInterface $quote, ?array $consent = null): ?BuyerInterface { - return $this->quoteToBuyerResponse->convert($quote); + return $this->quoteToBuyerResponse->convert($quote, $consent); + } + + /** + * @param string $maskedCartId + * @return array|null Consent as the agent recorded it + */ + private function getBuyerConsent(string $maskedCartId): ?array + { + try { + $meta = $this->checkoutMetaRepository->getByCheckoutId($maskedCartId); + } catch (NoSuchEntityException $exception) { + return null; + } + + $encoded = $meta->getBuyerConsent(); + + if ($encoded === null) { + return null; + } + + $decoded = json_decode($encoded, true); + + return is_array($decoded) ? $decoded : null; } /** @@ -196,20 +228,38 @@ public function getBuyer(CartInterface $quote): ?BuyerInterface */ public function getStatus(CartInterface $quote, array $validationErrors, bool $hasOrder = false): string { - // Placing an order deactivates the quote, so order state must win over quote state. - if ($hasOrder) { - return CheckoutResponseInterface::STATUS_COMPLETED; - } + $state = $this->stateResolver->resolve( + $quote, + $hasOrder, + $validationErrors !== [], + $this->needsBuyer($validationErrors) + ); - if (!$quote->getIsActive()) { - return CheckoutResponseInterface::STATUS_CANCELED; - } + return match ($state) { + CheckoutState::Completed => CheckoutResponseInterface::STATUS_COMPLETED, + CheckoutState::Canceled => CheckoutResponseInterface::STATUS_CANCELED, + CheckoutState::Incomplete => CheckoutResponseInterface::STATUS_INCOMPLETE, + CheckoutState::RequiresEscalation => CheckoutResponseInterface::STATUS_REQUIRES_ESCALATION, + CheckoutState::Ready => CheckoutResponseInterface::STATUS_READY_FOR_COMPLETE, + }; + } - if (!empty($validationErrors)) { - return CheckoutResponseInterface::STATUS_INCOMPLETE; + /** + * The spec ties escalation to severity: any error the buyer has to answer puts the whole session in + * `requires_escalation`, which is what tells the platform to hand off to the continue url. + * + * @param MessageInterface[] $validationErrors + * @return bool + */ + public function needsBuyer(array $validationErrors): bool + { + foreach ($validationErrors as $message) { + if (str_starts_with((string) $message->getSeverity(), 'requires_')) { + return true; + } } - return CheckoutResponseInterface::STATUS_READY_FOR_COMPLETE; + return false; } /** @@ -278,7 +328,7 @@ private function getSubmittedFulfillment(string $maskedCartId): ?array * @param CartInterface $quote * @return string|null RFC 3339 timestamp, or null when quotes do not expire */ - private function getExpiresAt(CartInterface $quote): ?string + public function getExpiresAt(CartInterface $quote): ?string { $storeId = (int)$quote->getStoreId(); $lifetimeDays = $this->config->getQuoteLifetimeDays($storeId); @@ -297,7 +347,7 @@ private function getExpiresAt(CartInterface $quote): ?string * @param string $maskedCartId * @return string Browser URL that hands the agent's cart back to the buyer */ - private function getContinueUrl(string $maskedCartId): string + public function getContinueUrl(string $maskedCartId): string { return $this->config->getApiBaseUrl() . '/ucp/checkout/resume/id/' . $maskedCartId; } @@ -308,13 +358,11 @@ private function getContinueUrl(string $maskedCartId): string */ protected function getOrder(string $checkoutId): ?OrderConfirmationInterface { - try { - $orderId = $this->checkoutMetaRepository->getByCheckoutId($checkoutId)->getOrderId(); - } catch (NoSuchEntityException $exception) { - return null; - } + // Read from the shared link rather than the meta row: placement is recorded in one place + // for both protocols now, and the meta row keeps only its protocol-specific fields. + $orderId = $this->orderLinkRepository->findOrderId(IdempotencyHandler::SCOPE, $checkoutId); - if (!$orderId) { + if ($orderId === null) { return null; } @@ -326,7 +374,9 @@ protected function getOrder(string $checkoutId): ?OrderConfirmationInterface /** @var OrderConfirmationInterface $confirmation */ $confirmation = $this->orderConfirmationFactory->create(); - $confirmation->setId((string) $order->getIncrementId()); + // Same identifier the order endpoint answers to, so the agent can come back for the order. + $confirmation->setId($checkoutId); + $confirmation->setLabel((string) $order->getIncrementId()); $confirmation->setPermalinkUrl( $this->config->getApiBaseUrl((int) $order->getStoreId()) . '/sales/order/view/order_id/' . $orderId diff --git a/Model/Service/Shopping/Converter/QuoteToDiscountResponse.php b/Model/Service/Shopping/Converter/QuoteToDiscountResponse.php index 741c369..8849eca 100644 --- a/Model/Service/Shopping/Converter/QuoteToDiscountResponse.php +++ b/Model/Service/Shopping/Converter/QuoteToDiscountResponse.php @@ -12,6 +12,7 @@ namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; +use Magebit\AgenticCore\Model\Money\MinorUnits; use Magebit\UcpSpec\Api\Shopping\DiscountResponseAppliedDiscountInterface; use Magebit\UcpSpec\Api\Shopping\DiscountResponseAppliedDiscountInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\DiscountResponseDiscountsObjectInterface; @@ -27,12 +28,12 @@ class QuoteToDiscountResponse /** * @param DiscountResponseDiscountsObjectInterfaceFactory $discountsObjectFactory * @param DiscountResponseAppliedDiscountInterfaceFactory $appliedDiscountFactory - * @param PriceConverter $priceConverter + * @param MinorUnits $minorUnits */ public function __construct( protected readonly DiscountResponseDiscountsObjectInterfaceFactory $discountsObjectFactory, protected readonly DiscountResponseAppliedDiscountInterfaceFactory $appliedDiscountFactory, - protected readonly PriceConverter $priceConverter, + protected readonly MinorUnits $minorUnits, ) { } @@ -91,7 +92,7 @@ private function appliedDiscount( /** @var DiscountResponseAppliedDiscountInterface $applied */ $applied = $this->appliedDiscountFactory->create(); $applied->setTitle((string)($description ?: $couponCode ?: self::DEFAULT_TITLE)); - $applied->setAmount($this->priceConverter->convert($amount, $currencyCode)); + $applied->setAmount($this->minorUnits->convert($amount, $currencyCode)); $applied->setAutomatic($couponCode === null); if ($couponCode !== null) { diff --git a/Model/Service/Shopping/Converter/QuoteToFulfillmentResponse.php b/Model/Service/Shopping/Converter/QuoteToFulfillmentResponse.php index 6444d18..d99c26f 100644 --- a/Model/Service/Shopping/Converter/QuoteToFulfillmentResponse.php +++ b/Model/Service/Shopping/Converter/QuoteToFulfillmentResponse.php @@ -12,11 +12,12 @@ namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; +use Magebit\AgenticCore\Model\Fulfillment\ShippingOption; +use Magebit\AgenticCore\Model\Fulfillment\ShippingOptionResolver; use Magento\Quote\Api\Data\CartInterface; use Magento\Quote\Model\Quote; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentResponseInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentResponseInterfaceFactory; -use Magento\Quote\Model\Quote\Address\Rate; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentMethodResponseInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentMethodResponseInterfaceFactory; use Magento\Quote\Model\Quote\Address; @@ -27,6 +28,7 @@ use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentDestinationResponseInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentDestinationResponseInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; class QuoteToFulfillmentResponse @@ -44,7 +46,7 @@ class QuoteToFulfillmentResponse * @param FulfillmentOptionResponseInterfaceFactory $fulfillmentOptionResponseFactory * @param FulfillmentDestinationResponseInterfaceFactory $fulfillmentDestinationResponseFactory * @param TotalResponseInterfaceFactory $totalResponseFactory - * @param PriceConverter $priceConverter + * @param ShippingOptionResolver $shippingOptionResolver */ public function __construct( protected readonly FulfillmentResponseInterfaceFactory $fulfillmentResponseFactory, @@ -53,7 +55,7 @@ public function __construct( protected readonly FulfillmentOptionResponseInterfaceFactory $fulfillmentOptionResponseFactory, protected readonly FulfillmentDestinationResponseInterfaceFactory $fulfillmentDestinationResponseFactory, protected readonly TotalResponseInterfaceFactory $totalResponseFactory, - protected readonly PriceConverter $priceConverter + protected readonly ShippingOptionResolver $shippingOptionResolver ) { } @@ -75,9 +77,7 @@ public function convert(CartInterface $quote, ?array $submitted = null): ?Fulfil return null; } - $shippingAddress->setCollectShippingRates(true); - $shippingAddress->collectShippingRates(); - + // The shared resolver collects the rates; nothing here needs to prompt it. $quoteItemIds = array_map(function (Quote\Item $quoteItem) { return (string) $quoteItem->getId(); }, $quote->getAllItems()); @@ -98,16 +98,9 @@ public function convert(CartInterface $quote, ?array $submitted = null): ?Fulfil */ public function getMethods(Address $shippingAddress, array $quoteItemIds, ?array $submittedMethod = null): array { - $shippingRates = $shippingAddress->getAllShippingRates(); - - $shippingRates = array_filter($shippingRates, function (Rate $shippingMethod) { - return !$shippingMethod->getErrorMessage(); - }); - - if (empty($shippingRates)) { - return []; - } - + // Reported even with nothing to offer yet: an agent that submitted a method with no destination + // has to see it come back, or it cannot tell whether the store understood the request. + $shippingOptions = $this->shippingOptionResolver->resolve($shippingAddress->getQuote()); $submittedGroup = $this->firstOf($submittedMethod, 'groups'); /** @var FulfillmentMethodResponseInterface $method */ @@ -118,28 +111,38 @@ public function getMethods(Address $shippingAddress, array $quoteItemIds, ?array ); $method->setLineItemIds($quoteItemIds); - $destination = $this->convertAddressToDestination($shippingAddress); + // The destination keeps the identifier the agent gave it, so the selection below names something + // the response actually lists. Renaming it to the quote address id left the reference dangling. + $submittedDestination = $this->firstOf($submittedMethod, 'destinations'); + $destination = $this->convertAddressToDestination( + $shippingAddress, + $this->submittedString($submittedDestination, 'id') + ); + if ($destination) { $method->setDestinations([$destination]); - $method->setSelectedDestinationId( - $this->submittedString($submittedMethod, 'selected_destination_id') ?? $destination->getId() - ); + $method->setSelectedDestinationId($destination->getId()); } $group = $this->fulfillmentGroupResponseFactory->create(); $group->setId($this->submittedString($submittedGroup, 'id') ?? self::DEFAULT_GROUP_ID); $group->setLineItemIds($quoteItemIds); - $currencyCode = $shippingAddress->getQuote()->getCurrency()?->getStoreCurrencyCode() ?? 'USD'; - $options = $this->convertShippingRatesToOptions($shippingRates, $currencyCode); + $options = $this->convertOptions($shippingOptions); if (!empty($options)) { $group->setOptions(array_values($options)); - $selectedOptionId = $this->submittedString($submittedGroup, 'selected_option_id') - ?? $shippingAddress->getShippingMethod(); + // Only a selection that names one of the options above is reported. Echoing back an id the + // store does not offer leaves a dangling reference, and tells the agent a shipping method is + // chosen when the order has none. + $selectedOptionId = $this->firstOfferedOption( + $options, + $this->submittedString($submittedGroup, 'selected_option_id'), + $shippingAddress->getShippingMethod() + ); - if ($selectedOptionId) { - $group->setSelectedOptionId((string)$selectedOptionId); + if ($selectedOptionId !== null) { + $group->setSelectedOptionId($selectedOptionId); } } @@ -180,10 +183,13 @@ private function submittedString(?array $submitted, string $key): ?string /** * @param Address $address + * @param string|null $submittedId Identifier the agent gave this destination, when it named one * @return FulfillmentDestinationResponseInterface|null */ - private function convertAddressToDestination(Address $address): ?FulfillmentDestinationResponseInterface - { + private function convertAddressToDestination( + Address $address, + ?string $submittedId = null + ): ?FulfillmentDestinationResponseInterface { if (!$address->getCountryId()) { return null; } @@ -193,7 +199,7 @@ private function convertAddressToDestination(Address $address): ?FulfillmentDest /** @var FulfillmentDestinationResponseInterface $destination */ $destination = $this->fulfillmentDestinationResponseFactory->create(); - $destination->setId((string) $address->getId()); + $destination->setId($submittedId ?? (string) $address->getId()); if ($streetAddress) { $destination->setStreetAddress($streetAddress); @@ -225,30 +231,90 @@ private function convertAddressToDestination(Address $address): ?FulfillmentDest } /** - * @param Rate[] $shippingRates - * @param string $currencyCode + * @param FulfillmentOptionResponseInterface[] $options Options the response lists + * @param string|null ...$candidates Selections to try, best first + * @return string|null The first candidate the store actually offers + */ + private function firstOfferedOption(array $options, ?string ...$candidates): ?string + { + $offered = []; + + foreach ($options as $option) { + $offered[] = (string) $option->getId(); + } + + foreach ($candidates as $candidate) { + if ($candidate !== null && $candidate !== '' && in_array($candidate, $offered, true)) { + return $candidate; + } + } + + return null; + } + + /** + * A breakdown that adds up: the shipping charge, its tax where there is any, and the total the + * option adds to the order. A consumer reading only the total still gets the full cost. + * + * @param ShippingOption[] $shippingOptions * @return FulfillmentOptionResponseInterface[] */ - private function convertShippingRatesToOptions(array $shippingRates, string $currencyCode): array + private function convertOptions(array $shippingOptions): array { - return array_map(function (Rate $rate) use ($currencyCode) { + return array_map(function (ShippingOption $shippingOption) { /** @var FulfillmentOptionResponseInterface $option */ $option = $this->fulfillmentOptionResponseFactory->create(); - $option->setId($rate->getCarrier() . '_' . $rate->getMethod()); - $option->setTitle($rate->getMethodTitle() ?: $rate->getCarrierTitle()); - $option->setDescription($rate->getMethodTitle() ? $rate->getCarrierTitle() : null); - $option->setCarrier($rate->getCarrierTitle()); + $option->setId($shippingOption->id); + $option->setTitle($shippingOption->title); + $option->setDescription($shippingOption->description); + $option->setCarrier($shippingOption->carrier); + $option->setTotals($this->optionTotals($shippingOption)); + + return $option; + }, $shippingOptions); + } + + /** + * @param ShippingOption $shippingOption + * @return TotalResponseInterface[] + */ + private function optionTotals(ShippingOption $shippingOption): array + { + $totals = [ + $this->total( + TotalTypeInterface::TYPE_FULFILLMENT, + $shippingOption->title, + $shippingOption->amountExclTax + ), + ]; + + if ($shippingOption->taxAmount > 0) { + $totals[] = $this->total(TotalTypeInterface::TYPE_TAX, 'Tax', $shippingOption->taxAmount); + } - // Create totals for the option - $price = (float) $rate->getPrice(); - $total = $this->totalResponseFactory->create(); - $total->setType(TotalResponseInterface::TYPE_FULFILLMENT); - $total->setAmount($this->priceConverter->convert($price, $currencyCode)); - $total->setDisplayText($rate->getMethodTitle() ?: $rate->getCarrierTitle()); + $totals[] = $this->total( + TotalTypeInterface::TYPE_TOTAL, + $shippingOption->title, + $shippingOption->amountInclTax + ); - $option->setTotals([$total]); + return $totals; + } - return $option; - }, $shippingRates); + /** + * @param string $type + * @param string $displayText + * @param int $amount Minor units + * @return TotalResponseInterface + */ + private function total(string $type, string $displayText, int $amount): TotalResponseInterface + { + /** @var TotalResponseInterface $total */ + $total = $this->totalResponseFactory->create(); + $total->setType($type); + $total->setAmount($amount); + $total->setDisplayText($displayText); + + return $total; } } diff --git a/Model/Service/Shopping/Converter/QuoteToTotalsResponse.php b/Model/Service/Shopping/Converter/QuoteToTotalsResponse.php index 9cdf045..e01f544 100644 --- a/Model/Service/Shopping/Converter/QuoteToTotalsResponse.php +++ b/Model/Service/Shopping/Converter/QuoteToTotalsResponse.php @@ -12,7 +12,10 @@ namespace Magebit\UniversalCommerce\Model\Service\Shopping\Converter; +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\AgenticCore\Model\Total\TypeLabel; use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; use Magento\Quote\Api\Data\CartInterface; use Magento\Quote\Model\Quote; @@ -27,23 +30,33 @@ class QuoteToTotalsResponse * subtotal - discount + fulfillment + tax + fee. */ private const TYPE_ORDER = [ - TotalResponseInterface::TYPE_ITEMS_DISCOUNT, - TotalResponseInterface::TYPE_SUBTOTAL, - TotalResponseInterface::TYPE_DISCOUNT, - TotalResponseInterface::TYPE_FULFILLMENT, - TotalResponseInterface::TYPE_TAX, - TotalResponseInterface::TYPE_FEE, - TotalResponseInterface::TYPE_TOTAL, + TotalTypeInterface::TYPE_ITEMS_DISCOUNT, + TotalTypeInterface::TYPE_SUBTOTAL, + TotalTypeInterface::TYPE_DISCOUNT, + TotalTypeInterface::TYPE_FULFILLMENT, + TotalTypeInterface::TYPE_TAX, + TotalTypeInterface::TYPE_FEE, + TotalTypeInterface::TYPE_TOTAL, + ]; + + /** + * Types the spec constrains to `exclusiveMaximum: 0`. Every other type is `minimum: 0`. + */ + private const NEGATIVE_TYPES = [ + TotalTypeInterface::TYPE_ITEMS_DISCOUNT, + TotalTypeInterface::TYPE_DISCOUNT, ]; /** * @param TotalResponseInterfaceFactory $totalResponseFactory - * @param PriceConverter $priceConverter + * @param MinorUnits $minorUnits + * @param TypeLabel $typeLabel * @param array $typeMapping */ public function __construct( private readonly TotalResponseInterfaceFactory $totalResponseFactory, - private readonly PriceConverter $priceConverter, + private readonly MinorUnits $minorUnits, + private readonly TypeLabel $typeLabel, private readonly array $typeMapping = [], ) { } @@ -68,8 +81,6 @@ public function convert(CartInterface $cart): array continue; } - // The spec requires amount >= 0, so a Magento discount's negative value - // is emitted as its magnitude and subtracted by the consumer. $amounts[$type] = ($amounts[$type] ?? 0.0) + abs((float) $cartTotal->getValue()); $labels[$type] ??= (string) $cartTotal->getTitle(); } @@ -86,8 +97,8 @@ public function convert(CartInterface $cart): array /** @var TotalResponseInterface $total */ $total = $this->totalResponseFactory->create(); $total->setType($type); - $total->setDisplayText($labels[$type] ?? $this->fallbackLabel($type)); - $total->setAmount($this->priceConverter->convert($amounts[$type], $currencyCode)); + $total->setDisplayText($this->typeLabel->orFallback($labels[$type] ?? null, $type)); + $total->setAmount($this->minorUnits->convert($this->signed($type, $amounts[$type]), $currencyCode)); $totals[] = $total; } @@ -105,19 +116,44 @@ public function convert(CartInterface $cart): array */ private function withRequiredTotals(Quote $cart, array $amounts): array { - if (!isset($amounts[TotalResponseInterface::TYPE_SUBTOTAL])) { + if (!isset($amounts[TotalTypeInterface::TYPE_SUBTOTAL])) { $address = $cart->getIsVirtual() ? $cart->getBillingAddress() : $cart->getShippingAddress(); $subtotal = $address->getSubtotal() ?? $cart->getSubtotal(); - $amounts[TotalResponseInterface::TYPE_SUBTOTAL] = abs((float) $subtotal); + $amounts[TotalTypeInterface::TYPE_SUBTOTAL] = abs((float) $subtotal); } - if (!isset($amounts[TotalResponseInterface::TYPE_TOTAL])) { - $amounts[TotalResponseInterface::TYPE_TOTAL] = abs((float) $cart->getGrandTotal()); + if (!isset($amounts[TotalTypeInterface::TYPE_TOTAL])) { + $amounts[TotalTypeInterface::TYPE_TOTAL] = abs((float) $cart->getGrandTotal()); + } + + // Magento reduces the grand total by a coupon without always emitting a discount row to go with + // it, which would leave the response showing a cheaper order for no stated reason. The amount is + // read off the address instead. + if (!isset($amounts[TotalTypeInterface::TYPE_DISCOUNT])) { + $address = $cart->getIsVirtual() ? $cart->getBillingAddress() : $cart->getShippingAddress(); + $discount = abs((float) $address->getDiscountAmount()); + + if ($discount > 0.0) { + $amounts[TotalTypeInterface::TYPE_DISCOUNT] = $discount; + } } return $amounts; } + /** + * The sign belongs to the type, not to whichever sign Magento happened to store the row with: + * the two discount codes that make up `discount` do not agree with each other. + * + * @param string $type + * @param float $magnitude + * @return float + */ + private function signed(string $type, float $magnitude): float + { + return in_array($type, self::NEGATIVE_TYPES, true) ? -abs($magnitude) : abs($magnitude); + } + /** * Map a Magento total code to a UCP type, or null when it has no spec equivalent. * @@ -130,13 +166,4 @@ private function mapType(string $magentoCode): ?string return in_array($type, self::TYPE_ORDER, true) ? $type : null; } - - /** - * @param string $type - * @return string - */ - private function fallbackLabel(string $type): string - { - return ucfirst(str_replace('_', ' ', $type)); - } } diff --git a/Model/Service/Shopping/OrderHandler.php b/Model/Service/Shopping/OrderHandler.php new file mode 100644 index 0000000..4af02ba --- /dev/null +++ b/Model/Service/Shopping/OrderHandler.php @@ -0,0 +1,132 @@ + + * @license MIT + */ +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\OrderResponseInterface; +use Magebit\UcpSpec\Api\Shopping\OrderUpdateRequestInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\OrderHandlerInterface; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\Order\AdjustmentRecorder; +use Magebit\UniversalCommerce\Model\Order\SessionOrderLookup; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToOrderResponse; +use Magento\Sales\Model\Order; + +class OrderHandler implements OrderHandlerInterface +{ + /** + * Freeform, because the specification's own list has no code for a field the business does not take + * from the platform. + */ + public const CODE_NOT_ACCEPTED = 'not_accepted'; + + /** + * @param SessionOrderLookup $orderLookup + * @param OrderToOrderResponse $orderConverter + * @param AdjustmentRecorder $adjustmentRecorder + * @param MessageInterfaceFactory $messageFactory + */ + public function __construct( + private readonly SessionOrderLookup $orderLookup, + private readonly OrderToOrderResponse $orderConverter, + private readonly AdjustmentRecorder $adjustmentRecorder, + private readonly MessageInterfaceFactory $messageFactory + ) { + } + + + /** + * @inheritDoc + */ + public function getOrder(string $checkoutId): OrderResponseInterface + { + return $this->orderConverter->convert($this->resolve($checkoutId), $checkoutId); + } + + /** + * Only the adjustments are taken from the request. Everything else it carries — line items, totals, + * the permalink — describes the order, which is the store's to state and not the agent's to change. + * + * @inheritDoc + */ + public function updateOrder(string $checkoutId, OrderUpdateRequestInterface $request): OrderResponseInterface + { + $order = $this->resolve($checkoutId); + + $this->adjustmentRecorder->record($order, $request->getAdjustments() ?? []); + + $response = $this->orderConverter->convert($order, $checkoutId); + $warning = $this->refusedShipmentWarning($request, $response); + + if ($warning !== null) { + $response->setMessages([$warning]); + } + + return $response; + } + + /** + * The shipment log says what the store actually did, so it is not the platform's to write. A + * submitted event is left out and said so, rather than being dropped without a word. + * + * @param OrderUpdateRequestInterface $request + * @param OrderResponseInterface $response + * @return MessageInterface|null + */ + private function refusedShipmentWarning( + OrderUpdateRequestInterface $request, + OrderResponseInterface $response + ): ?MessageInterface { + $submitted = $request->getFulfillment()->getEvents() ?? []; + $recorded = $response->getFulfillment()->getEvents() ?? []; + + if (count($submitted) <= count($recorded)) { + return null; + } + + /** @var MessageInterface $warning */ + $warning = $this->messageFactory->create(['data' => [ + MessageInterface::KEY_TYPE => 'warning', + MessageInterface::KEY_CODE => self::CODE_NOT_ACCEPTED, + MessageInterface::KEY_PATH => '$.fulfillment.events', + MessageInterface::KEY_CONTENT => 'This store records shipments itself, so the fulfillment ' + . 'events in this request were not added. The events reported here are the ones the ' + . 'store has shipped.', + ]]); + + return $warning; + } + + /** + * Orders are reached only through the checkout session that placed them. The session id cannot be + * guessed, while the store's own order number runs in sequence and anyone could count up to it. + * + * @param string $checkoutId + * @return Order + * @throws UcpException When that session placed no order this protocol may hand out + */ + private function resolve(string $checkoutId): Order + { + $order = $this->orderLookup->find($checkoutId); + + if ($order === null) { + throw new UcpException( + __('Order not found: %1.', $checkoutId), + 'not_found', + 404 + ); + } + + return $order; + } +} diff --git a/Model/Service/Shopping/ProfileUrlValidator.php b/Model/Service/Shopping/ProfileUrlValidator.php index 06e0c03..eb52e67 100644 --- a/Model/Service/Shopping/ProfileUrlValidator.php +++ b/Model/Service/Shopping/ProfileUrlValidator.php @@ -33,6 +33,14 @@ class ProfileUrlValidator '198.18.0.0/15', // RFC 2544 benchmarking ]; + /** + * @param TrustedProfileOrigins $trustedOrigins + */ + public function __construct( + private readonly TrustedProfileOrigins $trustedOrigins + ) { + } + /** * Resolve the host and confirm every address it maps to is publicly routable. * @@ -51,24 +59,26 @@ public function assertFetchable(string $url): array throw new InvalidArgumentException('Profile URL is not a valid absolute URL.'); } - if (!in_array(strtolower($parts['scheme']), self::ALLOWED_SCHEMES, true)) { - throw new InvalidArgumentException('Profile URL scheme is not allowed.'); - } - if (isset($parts['user']) || isset($parts['pass'])) { throw new InvalidArgumentException('Profile URL must not carry credentials.'); } - if (isset($parts['port']) && !in_array($parts['port'], self::ALLOWED_PORTS, true)) { - throw new InvalidArgumentException('Profile URL port is not allowed.'); + // An origin the deployment has named stays subject to resolution, but not to the scheme, port + // and routability rules — those are what stand in the way of an agent on the same network. + if ($this->trustedOrigins->trusts($url)) { + return $this->assertResolves($parts['host']); } - $addresses = $this->resolve($parts['host']); + if (!in_array(strtolower($parts['scheme']), self::ALLOWED_SCHEMES, true)) { + throw new InvalidArgumentException('Profile URL scheme is not allowed.'); + } - if ($addresses === []) { - throw new InvalidArgumentException('Profile URL host does not resolve.'); + if (isset($parts['port']) && !in_array($parts['port'], self::ALLOWED_PORTS, true)) { + throw new InvalidArgumentException('Profile URL port is not allowed.'); } + $addresses = $this->assertResolves($parts['host']); + foreach ($addresses as $address) { if (!$this->isPubliclyRoutable($address)) { throw new InvalidArgumentException('Profile URL resolves to a non-public address.'); @@ -78,6 +88,22 @@ public function assertFetchable(string $url): array return $addresses; } + /** + * @param string $host + * @return string[] + * @throws InvalidArgumentException When the host names nothing + */ + private function assertResolves(string $host): array + { + $addresses = $this->resolve($host); + + if ($addresses === []) { + throw new InvalidArgumentException('Profile URL host does not resolve.'); + } + + return $addresses; + } + /** * @param string $host * @return string[] @@ -93,18 +119,22 @@ private function resolve(string $host): array // An unresolvable host is an expected outcome here, not an error worth emitting. // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged $records = @dns_get_record($host, DNS_A | DNS_AAAA); + $addresses = []; - if ($records === false) { - return []; + foreach ($records === false ? [] : $records as $record) { + $addresses[] = $record['ip'] ?? $record['ipv6'] ?? null; } - $addresses = []; + // dns_get_record asks the name servers only, so it never sees a name from the hosts file. Those + // count too: an address the fetch would reach has to be an address that was checked. + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + $resolved = @gethostbynamel($host); - foreach ($records as $record) { - $addresses[] = $record['ip'] ?? $record['ipv6'] ?? null; + foreach ($resolved === false ? [] : $resolved as $address) { + $addresses[] = $address; } - return array_values(array_filter($addresses, 'is_string')); + return array_values(array_unique(array_filter($addresses, 'is_string'))); } /** diff --git a/Model/Service/Shopping/RestHandler.php b/Model/Service/Shopping/RestHandler.php index 24ca80d..231ff73 100644 --- a/Model/Service/Shopping/RestHandler.php +++ b/Model/Service/Shopping/RestHandler.php @@ -15,6 +15,7 @@ use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutUpdateRequestInterface; use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutCreateRequestInterface; use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutResponseInterface; +use Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseConsentInterface; use Magebit\UcpSpec\Api\Shopping\PaymentInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentRequestInterface; use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToCheckoutResponse; @@ -23,14 +24,24 @@ use Magento\Quote\Api\GuestCartRepositoryInterface; use Magento\Quote\Api\Data\CartInterface; use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Exception\UcpMessagesException; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\QuoteValidatorInterface; use Magento\Framework\Exception\LocalizedException; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Quote\Api\CartRepositoryInterface; use Magebit\UniversalCommerce\Api\CheckoutMetaRepositoryInterface; use Magebit\UniversalCommerce\Api\Data\CheckoutMetaInterface; use Magebit\UniversalCommerce\Api\Data\CheckoutMetaInterfaceFactory; +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\AgenticCore\Model\Order\Note; +use Magebit\UniversalCommerce\Model\Webhook\OrderEventNotifier; use Magento\Quote\Model\Quote; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; class RestHandler implements RestHandlerInterface { @@ -43,6 +54,13 @@ class RestHandler implements RestHandlerInterface * @param CheckoutMetaRepositoryInterface $checkoutMetaRepository * @param CheckoutMetaInterfaceFactory $checkoutMetaFactory * @param Config $config + * @param OrderLinkRepositoryInterface $orderLinkRepository + * @param OrderRepositoryInterface $orderRepository + * @param OrderEventNotifier $orderEventNotifier + * @param Note $orderNote + * @param CheckoutGuard $checkoutGuard + * @param QuoteValidatorInterface $quoteValidator + * @param MessageInterfaceFactory $messageFactory */ public function __construct( protected readonly CheckoutDataProcessor $checkoutDataProcessor, @@ -52,7 +70,14 @@ public function __construct( protected readonly CartRepositoryInterface $cartRepository, protected readonly CheckoutMetaRepositoryInterface $checkoutMetaRepository, protected readonly CheckoutMetaInterfaceFactory $checkoutMetaFactory, - protected readonly Config $config + protected readonly Config $config, + protected readonly OrderLinkRepositoryInterface $orderLinkRepository, + protected readonly OrderRepositoryInterface $orderRepository, + protected readonly OrderEventNotifier $orderEventNotifier, + protected readonly Note $orderNote, + protected readonly CheckoutGuard $checkoutGuard, + protected readonly QuoteValidatorInterface $quoteValidator, + protected readonly MessageInterfaceFactory $messageFactory ) { } @@ -67,7 +92,12 @@ public function createCheckout(CheckoutCreateRequestInterface $request): Checkou $this->checkoutDataProcessor->processCreateCheckoutRequest($cart, $request, $maskedCartId); $this->cartRepository->save($cart); - $this->recordCheckoutMeta($maskedCartId, (int) $cart->getId(), $request->getFulfillment()); + $this->recordCheckoutMeta( + $maskedCartId, + (int) $cart->getId(), + $request->getFulfillment(), + $request->getBuyer()?->getConsent() + ); return $this->quoteToCheckoutResponse->convert($cart, $maskedCartId); } @@ -92,10 +122,17 @@ public function cancelCheckout(string $checkoutId): CheckoutResponseInterface { $cart = $this->getCartByMaskedId($checkoutId); + if ($this->checkoutGuard->isCompleted($cart, $checkoutId)) { + throw new UcpException( + __('Checkout session %1 is already completed and can no longer be canceled.', $checkoutId), + CheckoutGuard::CODE_COMPLETED, + 409 + ); + } + if (!$cart->getIsActive()) { throw new UcpException( __('Checkout session is already canceled: %1.', $checkoutId), - 'error', 'checkout_already_canceled', 400 ); @@ -115,9 +152,11 @@ public function cancelCheckout(string $checkoutId): CheckoutResponseInterface public function updateCheckout(string $checkoutId, CheckoutUpdateRequestInterface $request): CheckoutResponseInterface { $cart = $this->getCartByMaskedId($checkoutId); + $this->checkoutGuard->assertOpen($cart, $checkoutId); $this->checkoutDataProcessor->processUpdateCheckoutRequest($cart, $request, $checkoutId); $this->cartRepository->save($cart); $this->rememberSubmittedFulfillment($checkoutId, $request->getFulfillment()); + $this->rememberBuyerConsent($checkoutId, $request->getBuyer()?->getConsent()); return $this->quoteToCheckoutResponse->convert($cart, $checkoutId); } @@ -151,14 +190,54 @@ public function completeCheckout(string $checkoutId, PaymentInterface $paymentDa try { $orderId = (int) $this->guestCartManagement->placeOrder($checkoutId); } catch (LocalizedException $e) { - throw new LocalizedException(__('Failed to place order: %1', $e->getMessage())); + // A refused order is the agent's to fix, so it reports as a 4xx naming every blocker rather + // than as a server error with one Magento sentence the agent can do nothing with. + throw new UcpMessagesException( + __('The checkout session could not be completed: %1', $e->getMessage()), + $this->completionBlockers($cart, $e->getMessage()), + 422 + ); } $this->linkOrder($checkoutId, $orderId); + // Notified here rather than from sales_order_place_after, which fires before the order is saved + // and before the link exists — leaving the notifier nothing to resolve the session from. + $placedOrder = $this->orderRepository->get($orderId); + + if ($placedOrder instanceof Order) { + $this->orderNote->add($placedOrder, (string) __('Placed by an agent over UCP.')); + $this->orderEventNotifier->notify($placedOrder); + } + return $this->quoteToCheckoutResponse->convert($cart, $checkoutId); } + /** + * Everything standing between this checkout and an order. The store's own refusal is kept as the + * last message, so nothing is lost when the readiness checks saw no problem. + * + * @param CartInterface $cart + * @param string $refusal Wording Magento refused the order with + * @return MessageInterface[] + */ + protected function completionBlockers(CartInterface $cart, string $refusal): array + { + $messages = $this->quoteValidator->validate($cart) ?? []; + + /** @var MessageInterface $message */ + $message = $this->messageFactory->create(['data' => [ + MessageInterface::KEY_TYPE => 'error', + MessageInterface::KEY_CODE => 'invalid_request', + MessageInterface::KEY_SEVERITY => MessageInterface::SEVERITY_REQUIRES_BUYER_INPUT, + MessageInterface::KEY_CONTENT => $refusal, + ]]); + + $messages[] = $message; + + return $messages; + } + /** * Get cart by masked ID * @@ -173,7 +252,6 @@ public function getCartByMaskedId(string $maskedCartId): CartInterface } catch (NoSuchEntityException $e) { throw new UcpException( __('Checkout session not found: %1. Please create a new checkout session.', $maskedCartId), - 'not_found', 'session_not_found', 404 ); @@ -185,18 +263,25 @@ public function getCartByMaskedId(string $maskedCartId): CartInterface /** * @param string $checkoutId * @param int $quoteId + * @param FulfillmentRequestInterface|null $fulfillment + * @param BuyerConsentResponseConsentInterface|null $consent * @return void */ protected function recordCheckoutMeta( string $checkoutId, int $quoteId, - ?FulfillmentRequestInterface $fulfillment = null + ?FulfillmentRequestInterface $fulfillment = null, + ?BuyerConsentResponseConsentInterface $consent = null ): void { /** @var CheckoutMetaInterface $meta */ $meta = $this->checkoutMetaFactory->create(); $meta->setCheckoutId($checkoutId); $meta->setQuoteId($quoteId); - $meta->setSubmittedFulfillment($this->encodeFulfillment($fulfillment)); + $meta->setSubmittedFulfillment($this->encode($fulfillment)); + $meta->setBuyerConsent($this->encode($consent)); + // Recorded at create time because the agent's header is only present on its own requests; an + // order event fires later, out of band, with no request to read it from. + $meta->setWebhookUrl($this->checkoutDataProcessor->getAgentWebhookUrl()); $this->checkoutMetaRepository->save($meta); } @@ -221,21 +306,47 @@ protected function rememberSubmittedFulfillment(string $checkoutId, ?Fulfillment return; } - $meta->setSubmittedFulfillment($this->encodeFulfillment($fulfillment)); + $meta->setSubmittedFulfillment($this->encode($fulfillment)); $this->checkoutMetaRepository->save($meta); } /** - * @param FulfillmentRequestInterface|null $fulfillment - * @return string|null + * Consent is a record of what the buyer agreed to, so a request that says nothing about it leaves + * the earlier answer standing rather than clearing it. + * + * @param string $checkoutId + * @param BuyerConsentResponseConsentInterface|null $consent + * @return void + */ + protected function rememberBuyerConsent( + string $checkoutId, + ?BuyerConsentResponseConsentInterface $consent + ): void { + if ($consent === null) { + return; + } + + try { + $meta = $this->checkoutMetaRepository->getByCheckoutId($checkoutId); + } catch (NoSuchEntityException $exception) { + return; + } + + $meta->setBuyerConsent($this->encode($consent)); + $this->checkoutMetaRepository->save($meta); + } + + /** + * @param object|null $value A spec object, which serialises back to the JSON it arrived as + * @return string|null JSON, or null when there is nothing to store */ - private function encodeFulfillment(?FulfillmentRequestInterface $fulfillment): ?string + private function encode(?object $value): ?string { - if ($fulfillment === null) { + if ($value === null) { return null; } - $encoded = json_encode($fulfillment); + $encoded = json_encode($value); return $encoded === false ? null : $encoded; } @@ -257,5 +368,14 @@ protected function linkOrder(string $checkoutId, int $orderId): void $meta->setOrderId($orderId); $this->checkoutMetaRepository->save($meta); + + // The shared link is what status resolution reads; the meta row keeps the protocol-specific + // fields and its own order_id stays written until the contract release drops it. + $this->orderLinkRepository->link( + IdempotencyHandler::SCOPE, + $checkoutId, + (int) $meta->getQuoteId(), + $orderId + ); } } diff --git a/Model/Service/Shopping/TrustedProfileOrigins.php b/Model/Service/Shopping/TrustedProfileOrigins.php new file mode 100644 index 0000000..e40dff8 --- /dev/null +++ b/Model/Service/Shopping/TrustedProfileOrigins.php @@ -0,0 +1,88 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping; + +use Magento\Framework\App\DeploymentConfig; + +/** + * Origins an agent profile may be fetched from even though the SSRF guard would refuse them — a + * conformance run or a staging agent on the same network, for instance. Read from app/etc/env.php so + * it is set per deployment and cannot be turned on from the admin panel. + */ +class TrustedProfileOrigins +{ + /** + * Where the list is read from in env.php. + */ + public const CONFIG_PATH = 'universal_commerce/trusted_profile_origins'; + + /** + * @param DeploymentConfig $deploymentConfig + */ + public function __construct( + private readonly DeploymentConfig $deploymentConfig + ) { + } + + /** + * The match is on the whole origin, so trusting one port on one host does not trust the rest of it. + * + * @param string $url + * @return bool + */ + public function trusts(string $url): bool + { + $origin = $this->originOf($url); + + return $origin !== null && in_array($origin, $this->configured(), true); + } + + /** + * @return string[] + */ + private function configured(): array + { + $value = $this->deploymentConfig->get(self::CONFIG_PATH); + + if (!is_array($value)) { + return []; + } + + $origins = []; + + foreach ($value as $origin) { + if (is_string($origin) && trim($origin) !== '') { + $origins[] = strtolower(rtrim(trim($origin), '/')); + } + } + + return $origins; + } + + /** + * @param string $url + * @return string|null Scheme, host and port, or null when the url names no host + */ + private function originOf(string $url): ?string + { + $parts = parse_url($url); + + if ($parts === false || !isset($parts['scheme'], $parts['host'])) { + return null; + } + + $origin = strtolower($parts['scheme']) . '://' . strtolower($parts['host']); + + return isset($parts['port']) ? $origin . ':' . $parts['port'] : $origin; + } +} diff --git a/Model/Service/Shopping/Validation/QuoteAddressValidator.php b/Model/Service/Shopping/Validation/QuoteAddressValidator.php index 51ac8f1..23ddae6 100644 --- a/Model/Service/Shopping/Validation/QuoteAddressValidator.php +++ b/Model/Service/Shopping/Validation/QuoteAddressValidator.php @@ -8,21 +8,49 @@ * @license MIT */ +declare(strict_types=1); + namespace Magebit\UniversalCommerce\Model\Service\Shopping\Validation; +use Magebit\AgenticCore\Model\Quote\ReadinessCheck; +use Magebit\AgenticCore\Model\Quote\Requirement; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; use Magebit\UniversalCommerce\Api\Service\Shopping\QuoteValidatorInterface; use Magento\Quote\Api\Data\CartInterface; use Magento\Quote\Model\Quote; -use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; -use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +/** + * Reports what the quote still needs, in this protocol's words. What is missing is decided by the + * shared check; only the wording and the path each thing reports at belong here. + */ class QuoteAddressValidator implements QuoteValidatorInterface { + /** + * Where this protocol carries each field, and what to say when it is absent. The buyer's own + * details sit at the top level; the address sits inside the fulfillment method's destination. + */ + private const DESTINATION = '$.fulfillment.methods[0].destination'; + + private const REPORTS = [ + 'FirstName' => ['First name is required', '$.buyer.first_name'], + 'LastName' => ['Last name is required', '$.buyer.last_name'], + 'Email' => ['An email address is required', '$.buyer.email'], + 'PhoneNumber' => ['Telephone is required', '$.buyer.phone_number'], + 'Street' => ['Street is required', self::DESTINATION . '.street_address'], + 'City' => ['City is required', self::DESTINATION . '.address_locality'], + 'Country' => ['Country is required', self::DESTINATION . '.address_country'], + 'Postcode' => ['Postcode is required', self::DESTINATION . '.postal_code'], + 'Region' => ['Region is required', self::DESTINATION . '.address_region'], + ]; + /** * @param MessageInterfaceFactory $messageFactory + * @param ReadinessCheck $readinessCheck */ public function __construct( - protected readonly MessageInterfaceFactory $messageFactory + protected readonly MessageInterfaceFactory $messageFactory, + protected readonly ReadinessCheck $readinessCheck ) { } @@ -33,76 +61,60 @@ public function __construct( public function validate(CartInterface $quote): array|null { /** @var Quote $quote */ - $errors = []; + $missing = $this->readinessCheck->contact($quote->getBillingAddress()); - $errors = array_merge($errors, $this->validateBillingAddress($quote)); - $errors = array_merge($errors, $this->validateShippingAddress($quote)); + if (!$quote->getCustomerEmail()) { + $missing[] = Requirement::Email; + } - return $errors; + if (!$quote->getIsVirtual()) { + $missing = array_merge($missing, $this->readinessCheck->postal($quote->getShippingAddress())); + } + + return $this->report(array_unique($missing, SORT_REGULAR), $quote); } /** - * @param CartInterface $quote + * @param Requirement[] $missing + * @param Quote $quote * @return MessageInterface[] */ - public function validateBillingAddress(CartInterface $quote): array + private function report(array $missing, Quote $quote): array { - /** @var Quote $quote */ - $billingAddress = $quote->getBillingAddress(); - - $errors = []; + $messages = []; - if (!$billingAddress->getFirstName()) { - $errors[] = $this->createMessage('First name is required', '$.buyer.first_name'); - } + foreach ($missing as $requirement) { + if ($requirement === Requirement::UnknownRegion) { + $messages[] = $this->unknownRegion($quote); - if (!$billingAddress->getLastName()) { - $errors[] = $this->createMessage('Last name is required', '$.buyer.last_name'); - } + continue; + } - if (!$billingAddress->getTelephone()) { - $errors[] = $this->createMessage('Telephone is required', '$.buyer.phone_number'); + [$content, $path] = self::REPORTS[$requirement->name]; + $messages[] = $this->createMessage($content, $path); } - return $errors; + return $messages; } /** - * @param CartInterface $quote - * @return MessageInterface[] + * @param Quote $quote + * @return MessageInterface */ - public function validateShippingAddress(CartInterface $quote): array + private function unknownRegion(Quote $quote): MessageInterface { - /** @var Quote $quote */ - $shippingAddress = $quote->getShippingAddress(); - - if ($quote->getIsVirtual()) { - return []; - } - - $errors = []; - - if (!$shippingAddress->getStreet()) { - $errors[] = $this->createMessage('Street is required', '$.fulfillment.methods[0].destination.street_address'); - } - - if (!$shippingAddress->getCity()) { - $errors[] = $this->createMessage('City is required', '$.fulfillment.methods[0].destination.address_locality'); - } - - if (!$shippingAddress->getCountry()) { - $errors[] = $this->createMessage('Country is required', '$.fulfillment.methods[0].destination.address_country'); - } - - if (!$shippingAddress->getPostcode()) { - $errors[] = $this->createMessage('Postcode is required', '$.fulfillment.methods[0].destination.postal_code'); - } - - if (!$shippingAddress->getRegion()) { - $errors[] = $this->createMessage('Region is required', '$.fulfillment.methods[0].destination.address_region'); - } - - return $errors; + $address = $quote->getShippingAddress(); + $region = $address->getData('region'); + + return $this->createMessage( + sprintf( + '"%s" is not a region of %s.', + is_string($region) ? $region : '', + (string) $address->getCountryId() + ), + self::DESTINATION . '.address_region', + 'invalid' + ); } /** diff --git a/Model/Service/Shopping/Validation/QuoteFulfillmentValidator.php b/Model/Service/Shopping/Validation/QuoteFulfillmentValidator.php new file mode 100644 index 0000000..8ad2e7d --- /dev/null +++ b/Model/Service/Shopping/Validation/QuoteFulfillmentValidator.php @@ -0,0 +1,101 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Service\Shopping\Validation; + +use Magebit\AgenticCore\Model\Fulfillment\ShippingOption; +use Magebit\AgenticCore\Model\Fulfillment\ShippingOptionResolver; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +use Magebit\UniversalCommerce\Api\Service\Shopping\QuoteValidatorInterface; +use Magento\Quote\Api\Data\CartInterface; +use Magento\Quote\Model\Quote; + +/** + * A shippable checkout is not finishable until one of the store's own fulfillment options is chosen, + * so both the missing choice and a choice the store does not offer are reported to the agent. + */ +class QuoteFulfillmentValidator implements QuoteValidatorInterface +{ + private const PATH_FULFILLMENT = '$.fulfillment'; + + private const PATH_SELECTED_OPTION = '$.fulfillment.methods[0].groups[0].selected_option_id'; + + /** + * @param MessageInterfaceFactory $messageFactory + * @param ShippingOptionResolver $shippingOptionResolver + */ + public function __construct( + private readonly MessageInterfaceFactory $messageFactory, + private readonly ShippingOptionResolver $shippingOptionResolver + ) { + } + + /** + * @param CartInterface $quote + * @return MessageInterface[]|null + */ + public function validate(CartInterface $quote): array|null + { + /** @var Quote $quote */ + if ($quote->getIsVirtual()) { + return []; + } + + $selected = (string) $quote->getShippingAddress()->getShippingMethod(); + + if ($selected === '') { + return [ + $this->createMessage( + 'A fulfillment option must be selected before this checkout can be completed.', + self::PATH_FULFILLMENT + ), + ]; + } + + // An unoffered selection is only reported once the store has options of its own to compare it + // against: with no rates at all the destination is what needs fixing, and it is reported there. + $offered = array_map( + fn (ShippingOption $option): string => $option->id, + $this->shippingOptionResolver->resolve($quote) + ); + + if ($offered === [] || in_array($selected, $offered, true)) { + return []; + } + + return [ + $this->createMessage( + sprintf('"%s" is not a fulfillment option this store offers.', $selected), + self::PATH_SELECTED_OPTION, + 'invalid' + ), + ]; + } + + /** + * @param string $content + * @param string $path + * @param string $code + * @return MessageInterface + */ + private function createMessage(string $content, string $path, string $code = 'missing'): MessageInterface + { + return $this->messageFactory->create(['data' => [ + MessageInterface::KEY_TYPE => 'error', + MessageInterface::KEY_PATH => $path, + MessageInterface::KEY_CODE => $code, + MessageInterface::KEY_SEVERITY => MessageInterface::SEVERITY_RECOVERABLE, + MessageInterface::KEY_CONTENT => $content, + ]]); + } +} diff --git a/Model/Service/Shopping/WebhookNotifier.php b/Model/Service/Shopping/WebhookNotifier.php deleted file mode 100644 index bd7147e..0000000 --- a/Model/Service/Shopping/WebhookNotifier.php +++ /dev/null @@ -1,10 +0,0 @@ - + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Simulation; + +use Magento\Framework\App\DeploymentConfig; + +/** + * Reads the shared secret that guards the conformance suite's simulation endpoint. It lives in + * app/etc/env.php so it is set per deployment and never appears in the admin panel or in config.xml. + */ +class Secret +{ + /** + * Where the secret is read from in env.php. + */ + public const CONFIG_PATH = 'universal_commerce/simulation_secret'; + + /** + * @param DeploymentConfig $deploymentConfig + */ + public function __construct( + private readonly DeploymentConfig $deploymentConfig + ) { + } + + /** + * No secret in env.php means the simulation endpoint is switched off entirely. + * + * @return bool + */ + public function isConfigured(): bool + { + return $this->value() !== null; + } + + /** + * @param string|null $candidate Value the caller sent + * @return bool + */ + public function matches(?string $candidate): bool + { + $secret = $this->value(); + + if ($secret === null || $candidate === null || $candidate === '') { + return false; + } + + // hash_equals takes the same time whichever byte differs, so the comparison cannot be used to + // guess the secret one character at a time. + return hash_equals($secret, $candidate); + } + + /** + * @return string|null + */ + private function value(): ?string + { + $value = $this->deploymentConfig->get(self::CONFIG_PATH); + + if (!is_string($value) || trim($value) === '') { + return null; + } + + return $value; + } +} diff --git a/Model/Spec/Schemas/Shopping/BuyerWithConsent.php b/Model/Spec/Schemas/Shopping/BuyerWithConsent.php new file mode 100644 index 0000000..839b344 --- /dev/null +++ b/Model/Spec/Schemas/Shopping/BuyerWithConsent.php @@ -0,0 +1,44 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Spec\Schemas\Shopping; + +use Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseBuyerInterface; +use Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseConsentInterface; +use Magebit\UcpSpec\Data\Shopping\Types\Buyer as GeneratedBuyer; +use Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface; + +/** + * The base buyer plus the consent the `buyer_consent` capability adds to it. + */ +class BuyerWithConsent extends GeneratedBuyer implements BuyerWithConsentInterface +{ + /** + * @return BuyerConsentResponseConsentInterface|null + */ + public function getConsent(): ?BuyerConsentResponseConsentInterface + { + return $this->instanceOrNull( + BuyerConsentResponseBuyerInterface::KEY_CONSENT, + BuyerConsentResponseConsentInterface::class + ); + } + + /** + * @param BuyerConsentResponseConsentInterface|null $consent + * @return self + */ + public function setConsent(?BuyerConsentResponseConsentInterface $consent): self + { + return $this->set(BuyerConsentResponseBuyerInterface::KEY_CONSENT, $consent); + } +} diff --git a/Model/Spec/Schemas/Shopping/CheckoutCreateRequest.php b/Model/Spec/Schemas/Shopping/CheckoutCreateRequest.php index 56c061e..5891257 100644 --- a/Model/Spec/Schemas/Shopping/CheckoutCreateRequest.php +++ b/Model/Spec/Schemas/Shopping/CheckoutCreateRequest.php @@ -17,7 +17,9 @@ use Magebit\UcpSpec\Api\Shopping\FulfillmentCreateRequestCheckoutInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentRequestInterface; use Magebit\UcpSpec\Data\Shopping\CheckoutCreateRequest as GeneratedCheckoutCreateRequest; +use Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface; use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutCreateRequestInterface; +use Magebit\UcpSpec\Api\Shopping\CheckoutCreateRequestInterface as BaseRequestInterface; /** * The base checkout request plus the fulfillment and discount extensions, which the spec ships as @@ -25,6 +27,19 @@ */ class CheckoutCreateRequest extends GeneratedCheckoutCreateRequest implements CheckoutCreateRequestInterface { + /** + * Narrowed alongside the interface, so the consent the agent sent survives being read back. + * + * @return BuyerWithConsentInterface|null + */ + public function getBuyer(): ?BuyerWithConsentInterface + { + return $this->instanceOrNull( + BaseRequestInterface::KEY_BUYER, + BuyerWithConsentInterface::class + ); + } + /** * @return FulfillmentRequestInterface|null */ diff --git a/Model/Spec/Schemas/Shopping/CheckoutResponse.php b/Model/Spec/Schemas/Shopping/CheckoutResponse.php index 4c5739e..2ebc603 100644 --- a/Model/Spec/Schemas/Shopping/CheckoutResponse.php +++ b/Model/Spec/Schemas/Shopping/CheckoutResponse.php @@ -12,6 +12,7 @@ namespace Magebit\UniversalCommerce\Model\Spec\Schemas\Shopping; +use Magebit\UcpSpec\Api\Shopping\CartResponseCheckoutInterface; use Magebit\UcpSpec\Api\Shopping\DiscountResponseCheckoutInterface; use Magebit\UcpSpec\Api\Shopping\DiscountResponseDiscountsObjectInterface; use Magebit\UcpSpec\Data\Shopping\FulfillmentResponseCheckout; @@ -42,4 +43,23 @@ public function setDiscounts(?DiscountResponseDiscountsObjectInterface $discount { return $this->set(DiscountResponseCheckoutInterface::KEY_DISCOUNTS, $discounts); } + + /** + * @return string|null + */ + public function getCartId(): ?string + { + return $this->stringOrNull(CartResponseCheckoutInterface::KEY_CART_ID); + } + + /** + * @param string|null $cartId + * @return CheckoutResponseInterface + */ + public function setCartId(?string $cartId): CheckoutResponseInterface + { + $this->set(CartResponseCheckoutInterface::KEY_CART_ID, $cartId); + + return $this; + } } diff --git a/Model/Spec/Schemas/Shopping/CheckoutUpdateRequest.php b/Model/Spec/Schemas/Shopping/CheckoutUpdateRequest.php index f5b0df0..761fc37 100644 --- a/Model/Spec/Schemas/Shopping/CheckoutUpdateRequest.php +++ b/Model/Spec/Schemas/Shopping/CheckoutUpdateRequest.php @@ -17,7 +17,9 @@ use Magebit\UcpSpec\Api\Shopping\FulfillmentUpdateRequestCheckoutInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentRequestInterface; use Magebit\UcpSpec\Data\Shopping\CheckoutUpdateRequest as GeneratedCheckoutUpdateRequest; +use Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface; use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutUpdateRequestInterface; +use Magebit\UcpSpec\Api\Shopping\CheckoutUpdateRequestInterface as BaseRequestInterface; /** * The base checkout request plus the fulfillment and discount extensions, which the spec ships as @@ -25,6 +27,19 @@ */ class CheckoutUpdateRequest extends GeneratedCheckoutUpdateRequest implements CheckoutUpdateRequestInterface { + /** + * Narrowed alongside the interface, so the consent the agent sent survives being read back. + * + * @return BuyerWithConsentInterface|null + */ + public function getBuyer(): ?BuyerWithConsentInterface + { + return $this->instanceOrNull( + BaseRequestInterface::KEY_BUYER, + BuyerWithConsentInterface::class + ); + } + /** * @return FulfillmentRequestInterface|null */ diff --git a/Model/Timestamp.php b/Model/Timestamp.php new file mode 100644 index 0000000..6239d7c --- /dev/null +++ b/Model/Timestamp.php @@ -0,0 +1,38 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model; + +/** + * Every timestamp the spec carries is RFC 3339, and every timestamp Magento stores is a UTC MySQL + * datetime. This is the one place that translates between them. + */ +class Timestamp +{ + /** + * @param string|null $databaseValue + * @return string|null Null when there is no timestamp to report + */ + public function toRfc3339(?string $databaseValue): ?string + { + if ($databaseValue === null || trim($databaseValue) === '') { + return null; + } + + try { + return (new \DateTimeImmutable($databaseValue, new \DateTimeZone('UTC'))) + ->format(\DateTimeInterface::RFC3339); + } catch (\Exception $exception) { + return null; + } + } +} diff --git a/Model/Validation/RequestValidator.php b/Model/Validation/RequestValidator.php deleted file mode 100644 index 3e42732..0000000 --- a/Model/Validation/RequestValidator.php +++ /dev/null @@ -1,314 +0,0 @@ - - * @copyright Copyright (c) Magebit, Ltd. (https://magebit.com) - * @license https://magebit.com/code-license - */ -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Model\Validation; - -use ReflectionClass; -use ReflectionMethod; -use ReflectionNamedType; - -/** - * Validates raw array data against interface method signatures using Reflection API - */ -class RequestValidator -{ - /** - * Validate array data against interface/class structure - * - * @param array $data Raw array data to validate - * @param string $className Fully qualified class or interface name - * @return ValidationResult - */ - public function validate(array $data, string $className): ValidationResult - { - if (!class_exists($className) && !interface_exists($className)) { - return new ValidationResult(['' => sprintf('Class or interface "%s" does not exist', $className)]); - } - - $reflection = new ReflectionClass($className); - $errors = $this->validateClass($data, $reflection, ''); - - return new ValidationResult($errors); - } - - /** - * Validate data against a reflection class - * - * @param array $data - * @param ReflectionClass $reflection - * @param string $pathPrefix Dot-notation path prefix for nested errors - * @return array - */ - private function validateClass(array $data, ReflectionClass $reflection, string $pathPrefix): array - { - $errors = []; - $methods = $reflection->getMethods(ReflectionMethod::IS_PUBLIC); - - foreach ($methods as $method) { - $methodName = $method->getName(); - - // Only process getter methods, skip common base methods - if (!str_starts_with($methodName, 'get') || $this->shouldSkipMethod($methodName)) { - continue; - } - - $key = $this->methodToKey($methodName); - $fullPath = $pathPrefix ? "$pathPrefix.$key" : $key; - $returnType = $method->getReturnType(); - - // Check if field is required (non-nullable) - $isRequired = $returnType !== null && !$returnType->allowsNull(); - - // Check if field exists in data - if (!array_key_exists($key, $data)) { - if ($isRequired) { - $errors[$fullPath] = sprintf('Required field "%s" is missing', $key); - } - continue; - } - - $value = $data[$key]; - - // Validate null values - if ($value === null) { - if ($isRequired) { - $errors[$fullPath] = sprintf('Field "%s" cannot be null', $key); - } - continue; - } - - // Validate based on return type - if ($returnType instanceof ReflectionNamedType) { - $typeErrors = $this->validateType($value, $returnType, $method, $fullPath); - $errors = array_merge($errors, $typeErrors); - } - } - - return $errors; - } - - /** - * Validate value against return type - * - * @param mixed $value - * @param ReflectionNamedType $returnType - * @param ReflectionMethod $method - * @param string $pathPrefix - * @return array - */ - private function validateType( - mixed $value, - ReflectionNamedType $returnType, - ReflectionMethod $method, - string $pathPrefix - ): array { - $typeName = $returnType->getName(); - $errors = []; - - // Handle array types - if ($typeName === 'array') { - if (!is_array($value)) { - return [$pathPrefix => sprintf('Field "%s" must be an array', basename($pathPrefix))]; - } - - // Try to extract array element type from PHPDoc - $elementType = $this->extractArrayElementType($method); - if ($elementType !== null) { - foreach ($value as $index => $item) { - $itemPath = "$pathPrefix.$index"; - if (!is_array($item)) { - $errors[$itemPath] = sprintf('Array item at index %d must be an array', $index); - continue; - } - - $itemErrors = $this->validateClass($item, new ReflectionClass($elementType), $itemPath); - $errors = array_merge($errors, $itemErrors); - } - } - return $errors; - } - - // Handle scalar types - if ($this->isScalarType($typeName)) { - if (!$this->validateScalarType($value, $typeName)) { - $errors[$pathPrefix] = sprintf( - 'Field "%s" must be of type %s, got %s', - basename($pathPrefix), - $typeName, - gettype($value) - ); - } - return $errors; - } - - // Handle interface/class types (nested objects) - if (class_exists($typeName) || interface_exists($typeName)) { - if (!is_array($value)) { - return [$pathPrefix => sprintf('Field "%s" must be an array for nested object', basename($pathPrefix))]; - } - - $nestedErrors = $this->validateClass($value, new ReflectionClass($typeName), $pathPrefix); - return $nestedErrors; - } - - return $errors; - } - - /** - * Extract array element type from PHPDoc comment - * - * @param ReflectionMethod $method - * @return string|null Fully qualified class/interface name or null - */ - private function extractArrayElementType(ReflectionMethod $method): ?string - { - $docComment = $method->getDocComment(); - if ($docComment === false) { - return null; - } - - // Match @return SomeInterface[] or @return \Namespace\SomeInterface[] - if (preg_match('/@return\s+([^\s\[\]]+)\[\]/', $docComment, $matches)) { - $typeName = trim($matches[1]); - return $this->resolveTypeName($typeName, $method->getDeclaringClass()); - } - - return null; - } - - /** - * Resolve type name to fully qualified class/interface name - * - * @param string $typeName Type name from PHPDoc (may be short or fully qualified) - * @param ReflectionClass $declaringClass Class where the type is used - * @return string|null Fully qualified class/interface name or null if not found - */ - private function resolveTypeName(string $typeName, ReflectionClass $declaringClass): ?string - { - // Remove leading backslash if present - $typeName = ltrim($typeName, '\\'); - - // If already fully qualified, check if it exists - if (str_contains($typeName, '\\')) { - if (class_exists($typeName) || interface_exists($typeName)) { - return $typeName; - } - return null; - } - - // Try to resolve from use statements - $resolvedType = $this->resolveFromUseStatements($typeName, $declaringClass); - if ($resolvedType !== null) { - return $resolvedType; - } - - // Try relative to declaring class namespace - $namespace = $declaringClass->getNamespaceName(); - if ($namespace !== '') { - $fullName = $namespace . '\\' . $typeName; - if (class_exists($fullName) || interface_exists($fullName)) { - return $fullName; - } - } - - return null; - } - - /** - * Resolve type name from use statements in the file - * - * @param string $typeName Short type name - * @param ReflectionClass $declaringClass - * @return string|null Fully qualified name or null - */ - private function resolveFromUseStatements(string $typeName, ReflectionClass $declaringClass): ?string - { - $fileName = $declaringClass->getFileName(); - if ($fileName === false || !file_exists($fileName)) { - return null; - } - - $fileContent = file_get_contents($fileName); - if ($fileContent === false) { - return null; - } - - // Extract use statements - // Match: use Fully\Qualified\ClassName; or use Fully\Qualified\ClassName as Alias; - if (preg_match_all('/^use\s+([^\s;]+)(?:\s+as\s+(\w+))?;/m', $fileContent, $matches, PREG_SET_ORDER)) { - foreach ($matches as $match) { - $fullName = trim($match[1]); - $alias = $match[2] ?? null; - - // Check if the type name matches the alias or the last part of the full name - $lastPart = substr($fullName, strrpos($fullName, '\\') + 1); - if ($typeName === $alias || $typeName === $lastPart) { - if (class_exists($fullName) || interface_exists($fullName)) { - return $fullName; - } - } - } - } - - return null; - } - - /** - * Convert method name to snake_case key - * - * @param string $methodName e.g., "getFooBar" - * @return string e.g., "foo_bar" - */ - private function methodToKey(string $methodName): string - { - $name = substr($methodName, 3); // Remove 'get' prefix - return strtolower(preg_replace('/(? is_string($value), - 'int' => is_int($value), - 'float' => is_float($value) || is_int($value), // Allow int for float - 'bool' => is_bool($value), - default => false, - }; - } -} diff --git a/Model/Validation/ValidationResult.php b/Model/Validation/ValidationResult.php deleted file mode 100644 index 9fe0f87..0000000 --- a/Model/Validation/ValidationResult.php +++ /dev/null @@ -1,44 +0,0 @@ - - * @copyright Copyright (c) Magebit, Ltd. (https://magebit.com) - * @license https://magebit.com/code-license - */ -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Model\Validation; - -/** - * Validation result container - */ -class ValidationResult -{ - /** - * @param array $errors Array of field path => error message - */ - public function __construct( - private readonly array $errors = [] - ) { - } - - /** - * Check if validation passed - * - * @return bool - */ - public function isValid(): bool - { - return empty($this->errors); - } - - /** - * Get all validation errors - * - * @return array Field path => error message - */ - public function getErrors(): array - { - return $this->errors; - } -} diff --git a/Model/Webhook/DeliveryHeadersProvider.php b/Model/Webhook/DeliveryHeadersProvider.php new file mode 100644 index 0000000..7fdb47d --- /dev/null +++ b/Model/Webhook/DeliveryHeadersProvider.php @@ -0,0 +1,172 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Webhook; + +use Magebit\AgenticCore\Api\Data\WebhookDeliveryInterface; +use Magebit\AgenticCore\Api\SigningKeyRepositoryInterface; +use Magebit\AgenticCore\Api\Webhook\DeliveryHeadersProviderInterface; +use Magebit\AgenticCore\Model\Signature\MessageSigner; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Config; + +/** + * This protocol's order-event headers, signed per RFC 9421 because the spec requires it of webhooks. + */ +class DeliveryHeadersProvider implements DeliveryHeadersProviderInterface +{ + /** + * Body digest per RFC 9530. + */ + private const DIGEST_HEADER = 'Content-Digest'; + + /** + * The event's own identifier and occurrence time, both fixed when it happened: a retry is the same + * event, so neither may move between attempts. + */ + private const EVENT_ID_HEADER = 'Webhook-Id'; + private const EVENT_TIMESTAMP_HEADER = 'Webhook-Timestamp'; + + /** + * Identifies the sender by its own discovery profile, in RFC 8941 dictionary syntax. + */ + private const AGENT_HEADER = 'UCP-Agent'; + + /** + * The discovery path is fixed by the spec, so the profile URI is the module's own API base plus it. + */ + private const PROFILE_PATH = '/.well-known/ucp'; + + /** + * The Content-Type the sender attaches. It is signed, so the two have to agree exactly. + */ + private const CONTENT_TYPE = 'application/json'; + + /** + * The key set is published at one profile per site, which is the default scope the profile URL is + * itself resolved in. + */ + private const KEY_STORE_ID = 0; + + /** + * @param Config $config + * @param SigningKeyRepositoryInterface $signingKeys + * @param MessageSigner $signer + */ + public function __construct( + private readonly Config $config, + private readonly SigningKeyRepositoryInterface $signingKeys, + private readonly MessageSigner $signer + ) { + } + + /** + * @inheritDoc + * @throws \RuntimeException + */ + public function getHeaders(WebhookDeliveryInterface $delivery, int $attemptTimestamp): array + { + $agent = sprintf('profile="%s"', $this->config->getApiBaseUrl() . self::PROFILE_PATH); + $digest = $this->digestOf((string) $delivery->getPayload()); + + $headers = [ + self::DIGEST_HEADER => $digest, + self::EVENT_ID_HEADER => (string) $delivery->getReference(), + self::EVENT_TIMESTAMP_HEADER => (string) $this->occurredAt($delivery), + self::AGENT_HEADER => $agent, + ]; + + return $headers + $this->signatureFor((string) $delivery->getUrl(), $agent, $digest); + } + + /** + * @param string $url Where this delivery is going + * @param string $agent + * @param string $digest + * @return array + * @throws \RuntimeException + */ + private function signatureFor(string $url, string $agent, string $digest): array + { + $key = $this->signingKeys->getSigningKey(IdempotencyHandler::SCOPE, self::KEY_STORE_ID); + $pem = $key->getPrivateKeyPem(); + + if ($pem === null) { + throw new \RuntimeException('The signing key for order events could not be read.'); + } + + return $this->signer->sign( + [ + '@method' => 'POST', + // The receiver's host and path, not ours: a signature over our own would still verify + // after the delivery was relayed somewhere else. + '@authority' => $this->authorityOf($url), + '@path' => $this->pathOf($url), + 'ucp-agent' => $agent, + 'content-digest' => $digest, + 'content-type' => self::CONTENT_TYPE, + ], + (string) $key->getKid(), + $pem + ); + } + + /** + * Host lowercased with the default port dropped, per RFC 9421. + * + * @param string $url + * @return string + */ + private function authorityOf(string $url): string + { + $host = strtolower((string) parse_url($url, PHP_URL_HOST)); + $port = parse_url($url, PHP_URL_PORT); + $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); + $isDefault = ($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80); + + return $port === null || $isDefault ? $host : $host . ':' . $port; + } + + /** + * @param string $url + * @return string + */ + private function pathOf(string $url): string + { + $path = (string) parse_url($url, PHP_URL_PATH); + + return $path === '' ? '/' : $path; + } + + /** + * @param string $payload + * @return string + */ + private function digestOf(string $payload): string + { + return 'sha-256=:' . base64_encode(hash('sha256', $payload, true)) . ':'; + } + + /** + * When the event happened, not when this attempt is being made. Falls back to the attempt only if + * the row somehow carries no creation time, which the schema does not allow. + * + * @param WebhookDeliveryInterface $delivery + * @return int + */ + private function occurredAt(WebhookDeliveryInterface $delivery): int + { + $createdAt = (string) $delivery->getCreatedAt(); + + return $createdAt === '' ? 0 : (int) strtotime($createdAt . ' UTC'); + } +} diff --git a/Model/Webhook/OrderEventNotifier.php b/Model/Webhook/OrderEventNotifier.php new file mode 100644 index 0000000..19f0770 --- /dev/null +++ b/Model/Webhook/OrderEventNotifier.php @@ -0,0 +1,125 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Model\Webhook; + +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; +use Magebit\AgenticCore\Model\Webhook\Dispatcher; +use Magebit\UniversalCommerce\Api\CheckoutMetaRepositoryInterface; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToOrderResponse; +use Magento\Framework\DataObject\IdentityGeneratorInterface; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Sales\Model\Order; +use Psr\Log\LoggerInterface; + +/** + * Queues an order lifecycle event for the agent that created the checkout. Only queues: delivery, + * headers and retries belong to the shared dispatcher, so nothing an order does waits on a receiver. + */ +class OrderEventNotifier +{ + /** + * @param Dispatcher $dispatcher + * @param OrderLinkRepositoryInterface $orderLinkRepository + * @param CheckoutMetaRepositoryInterface $checkoutMetaRepository + * @param OrderToOrderResponse $orderConverter + * @param IdentityGeneratorInterface $identityGenerator + * @param Config $config + * @param LoggerInterface $logger + */ + public function __construct( + private readonly Dispatcher $dispatcher, + private readonly OrderLinkRepositoryInterface $orderLinkRepository, + private readonly CheckoutMetaRepositoryInterface $checkoutMetaRepository, + private readonly OrderToOrderResponse $orderConverter, + private readonly IdentityGeneratorInterface $identityGenerator, + private readonly Config $config, + private readonly LoggerInterface $logger + ) { + } + + /** + * @param Order $order + * @return void + */ + public function notify(Order $order): void + { + // Checked before anything is built: a merchant who has not enabled delivery must not accumulate + // rows nobody will ever send, and the credential scheme is still undecided. + if (!$this->config->areWebhooksEnabled((int) $order->getStoreId())) { + return; + } + + $checkoutId = $this->checkoutIdOf($order); + + if ($checkoutId === null) { + return; + } + + $url = $this->webhookUrlOf($checkoutId); + + // An agent that declared no webhook URL in its profile is not asking to be told. + if ($url === null) { + return; + } + + try { + $this->dispatcher->enqueue( + IdempotencyHandler::SCOPE, + $url, + (string) json_encode($this->orderConverter->convert($order, $checkoutId)), + // The event's own identifier, which the delivery reports as Webhook-Id and keeps across + // retries. One per event, not one per order or per session. + $this->identityGenerator->generateId() + ); + } catch (\Exception $exception) { + // An order that already happened must not be undone because its notification could not be + // queued. + $this->logger->critical('Could not queue an order event webhook', [ + 'exception' => $exception, + 'order_id' => $order->getIncrementId(), + ]); + } + } + + /** + * @param Order $order + * @return string|null Null when the order came from no session of this protocol + */ + private function checkoutIdOf(Order $order): ?string + { + $entityId = $order->getEntityId(); + + if (!is_numeric($entityId)) { + return null; + } + + return $this->orderLinkRepository->findSessionId(IdempotencyHandler::SCOPE, (int) $entityId); + } + + /** + * @param string $checkoutId + * @return string|null + */ + private function webhookUrlOf(string $checkoutId): ?string + { + try { + $url = (string) $this->checkoutMetaRepository->getByCheckoutId($checkoutId)->getWebhookUrl(); + } catch (NoSuchEntityException $exception) { + return null; + } + + return $url === '' ? null : $url; + } +} diff --git a/Observer/EnqueueOnCancel.php b/Observer/EnqueueOnCancel.php new file mode 100644 index 0000000..1d15170 --- /dev/null +++ b/Observer/EnqueueOnCancel.php @@ -0,0 +1,47 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Observer; + +use Magebit\UniversalCommerce\Model\Webhook\OrderEventNotifier; +use Magento\Framework\Event\Observer; +use Magento\Framework\Event\ObserverInterface; +use Magento\Sales\Model\Order; + +/** + * Cancellation is the one lifecycle change no sales document represents. The rest — moving to processing, + * to complete — follow from an invoice or a shipment, which have their own observers, so watching every + * order save instead would send the same event twice. + */ +class EnqueueOnCancel implements ObserverInterface +{ + /** + * @param OrderEventNotifier $notifier + */ + public function __construct( + private readonly OrderEventNotifier $notifier + ) { + } + + /** + * @param Observer $observer + * @return void + */ + public function execute(Observer $observer): void + { + $order = $observer->getEvent()->getData('order'); + + if ($order instanceof Order) { + $this->notifier->notify($order); + } + } +} diff --git a/Observer/EnqueueOnCreditmemo.php b/Observer/EnqueueOnCreditmemo.php new file mode 100644 index 0000000..461bcd0 --- /dev/null +++ b/Observer/EnqueueOnCreditmemo.php @@ -0,0 +1,48 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Observer; + +use Magebit\UniversalCommerce\Model\Webhook\OrderEventNotifier; +use Magento\Framework\Event\Observer; +use Magento\Framework\Event\ObserverInterface; +use Magento\Sales\Model\Order\Creditmemo; + +/** + * A credit memo is a refund adjustment, which moves money and changes the active quantities. + * + * Bound to the commit-after event so the memo's items and the order's own refunded quantities are both in + * place before the body is built. + */ +class EnqueueOnCreditmemo implements ObserverInterface +{ + /** + * @param OrderEventNotifier $notifier + */ + public function __construct( + private readonly OrderEventNotifier $notifier + ) { + } + + /** + * @param Observer $observer + * @return void + */ + public function execute(Observer $observer): void + { + $creditmemo = $observer->getEvent()->getData('creditmemo'); + + if ($creditmemo instanceof Creditmemo) { + $this->notifier->notify($creditmemo->getOrder()); + } + } +} diff --git a/Observer/EnqueueOnShipment.php b/Observer/EnqueueOnShipment.php new file mode 100644 index 0000000..243b160 --- /dev/null +++ b/Observer/EnqueueOnShipment.php @@ -0,0 +1,48 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Observer; + +use Magebit\UniversalCommerce\Model\Webhook\OrderEventNotifier; +use Magento\Framework\Event\Observer; +use Magento\Framework\Event\ObserverInterface; +use Magento\Sales\Model\Order\Shipment; + +/** + * A shipment is an entry in the order's fulfillment event log, which is the whole point of the log. + * + * Bound to the commit-after event, not save-after: at save-after the shipment row exists but its items do + * not, so the body would report a shipment of nothing and the order as still unshipped. + */ +class EnqueueOnShipment implements ObserverInterface +{ + /** + * @param OrderEventNotifier $notifier + */ + public function __construct( + private readonly OrderEventNotifier $notifier + ) { + } + + /** + * @param Observer $observer + * @return void + */ + public function execute(Observer $observer): void + { + $shipment = $observer->getEvent()->getData('shipment'); + + if ($shipment instanceof Shipment) { + $this->notifier->notify($shipment->getOrder()); + } + } +} diff --git a/README.md b/README.md index e16dcca..32c5f14 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,25 @@ The Universal Commerce Protocol (UCP) is an open-source standard designed to pow UCP is developed by Google in collaboration with industry leaders including Shopify, Etsy, Wayfair, Target, and Walmart endorsed by over 20 global partners across the ecosystem like Adyen, American Express, Best Buy, Flipkart, Macy's Inc, Mastercard, Stripe, The Home Depot, Visa, Zalando and many more. +## Conformance testing + +The UCP conformance suite needs a way to move an order to shipped so it can check the order event that +follows. That is not part of UCP: the suite hardcodes the path `POST /testing/simulate-shipping/{order_id}` +and a `Simulation-Secret` header, copying the reference sample server. + +The endpoint stays switched off until a secret is set in `app/etc/env.php`: + +```php +'universal_commerce' => [ + 'simulation_secret' => 'a-long-random-value' +], +``` + +Remove the key to switch it off again. With no secret set the endpoint answers `404` as though it were +never routed, and it only ever ships orders that UCP placed itself — never the storefront's. + +Do not set this on a production store unless you are running the suite against it. + ## Contributing Found a bug, have a feature suggestion or just want to help in general? Contributions are very welcome! Check out the list of active issues or submit one yourself. diff --git a/Setup/Patch/Data/BackfillOrderLinks.php b/Setup/Patch/Data/BackfillOrderLinks.php new file mode 100644 index 0000000..d9853d2 --- /dev/null +++ b/Setup/Patch/Data/BackfillOrderLinks.php @@ -0,0 +1,98 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Setup\Patch\Data; + +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Sql\Expression; +use Magento\Framework\Setup\ModuleDataSetupInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; + +class BackfillOrderLinks implements DataPatchInterface +{ + /** + * Left declared in db_schema.xml on purpose, along with its order_id column. Declarative schema + * runs before data patches, so a release that dropped either would delete the rows below before + * this patch could read them. + */ + private const SOURCE_TABLE = 'ucp_checkout_meta'; + + private const TARGET_TABLE = 'agentic_checkout_order_link'; + + /** + * @param ModuleDataSetupInterface $moduleDataSetup + */ + public function __construct( + private readonly ModuleDataSetupInterface $moduleDataSetup + ) { + } + + /** + * Copies every session-to-order link into the shared table under this module's scope. + * + * @return $this + */ + public function apply(): self + { + $connection = $this->moduleDataSetup->getConnection(); + $source = $this->moduleDataSetup->getTable(self::SOURCE_TABLE); + $target = $this->moduleDataSetup->getTable(self::TARGET_TABLE); + + if (!$connection->isTableExists($source)) { + return $this; + } + + $select = $connection->select() + ->from($source, [ + // A module constant, not input: quote() is typed mixed upstream and cannot be cast + // under level 9, and there is nothing here to escape. + new Expression("'" . IdempotencyHandler::SCOPE . "'"), + 'checkout_id', + 'quote_id', + 'order_id', + ]) + // Deliberately unfiltered beyond requiring a link to exist. Unlike the idempotency copy, + // a session-to-order link has no expiry and no cron that deletes it, so every row is live + // no matter how old — filtering here would lose data rather than bound the work. Losing + // one means a completed checkout reports canceled for good. + ->where('order_id IS NOT NULL'); + + // INSERT_IGNORE makes the patch re-runnable, so a run interrupted partway is safe to repeat. + $connection->query( + $connection->insertFromSelect( + $select, + $target, + ['scope', 'session_id', 'quote_id', 'order_id'], + AdapterInterface::INSERT_IGNORE + ) + ); + + return $this; + } + + /** + * @return array + */ + public static function getDependencies(): array + { + return []; + } + + /** + * @return array + */ + public function getAliases(): array + { + return []; + } +} diff --git a/Setup/Patch/Data/MigrateIdempotencyKeys.php b/Setup/Patch/Data/MigrateIdempotencyKeys.php new file mode 100644 index 0000000..8a38f06 --- /dev/null +++ b/Setup/Patch/Data/MigrateIdempotencyKeys.php @@ -0,0 +1,113 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Setup\Patch\Data; + +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Sql\Expression; +use Magento\Framework\Setup\ModuleDataSetupInterface; +use Magento\Framework\Setup\Patch\DataPatchInterface; + +class MigrateIdempotencyKeys implements DataPatchInterface +{ + /** + * Left declared in db_schema.xml on purpose. Declarative schema runs before data patches, so a + * release that dropped this table would delete the rows below before this patch could read them. + */ + private const SOURCE_TABLE = 'ucp_idempotency_keys'; + + private const TARGET_TABLE = 'agentic_idempotency'; + + /** + * @param ModuleDataSetupInterface $moduleDataSetup + * @param Config $config + */ + public function __construct( + private readonly ModuleDataSetupInterface $moduleDataSetup, + private readonly Config $config + ) { + } + + /** + * Copies this module's still-live rows into the shared table under its own scope. + * + * @return $this + */ + public function apply(): self + { + $connection = $this->moduleDataSetup->getConnection(); + $source = $this->moduleDataSetup->getTable(self::SOURCE_TABLE); + $target = $this->moduleDataSetup->getTable(self::TARGET_TABLE); + + if (!$connection->isTableExists($source)) { + return $this; + } + + $select = $connection->select() + ->from($source, [ + // A module constant, not input: quote() is typed mixed upstream and cannot be cast + // under level 9, and there is nothing here to escape. + new Expression("'" . IdempotencyHandler::SCOPE . "'"), + 'key', + 'request_hash', + 'response_status', + 'response_body', + 'created_at', + 'updated_at', + ]) + // A row past its configured lifetime is already dead — the hourly cron exists to delete + // it. Copying the whole history would move unbounded rows under maintenance mode for no + // behavioural gain, so the work is capped at one TTL window of traffic. + ->where( + 'created_at >= UTC_TIMESTAMP() - INTERVAL ? HOUR', + max(1, $this->config->getIdempotencyTtlHours()) + ); + + // INSERT_IGNORE makes the patch re-runnable, so a run interrupted partway is safe to repeat. + $connection->query( + $connection->insertFromSelect( + $select, + $target, + [ + 'scope', + 'idempotency_key', + 'request_hash', + 'response_status', + 'response_body', + 'created_at', + 'updated_at', + ], + AdapterInterface::INSERT_IGNORE + ) + ); + + return $this; + } + + /** + * @return array + */ + public static function getDependencies(): array + { + return []; + } + + /** + * @return array + */ + public function getAliases(): array + { + return []; + } +} diff --git a/Test/Unit/Api/SpecTargetTest.php b/Test/Unit/Api/SpecTargetTest.php new file mode 100644 index 0000000..be0a877 --- /dev/null +++ b/Test/Unit/Api/SpecTargetTest.php @@ -0,0 +1,74 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Api; + +use Magebit\UcpSpec\Runtime\SpecObject; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; +use PHPUnit\Framework\TestCase; + +/** + * The module advertises a protocol version and builds its payloads from the spec library's generated + * types. If the two disagree, the endpoint tells agents it speaks a version it does not. + */ +class SpecTargetTest extends TestCase +{ + /** + * @return void + */ + public function testTheAdvertisedVersionMatchesTheInstalledSpecTarget(): void + { + $manifest = $this->manifest(); + + $this->assertSame( + $manifest['spec']['target'] ?? null, + UniversalCommerceProtocolInterface::SPEC_VERSION, + 'The advertised SPEC_VERSION and the installed spec library target have drifted apart.' + ); + } + + /** + * A target with no recorded upstream commit cannot be reproduced, so a release must not carry one. + * + * @return void + */ + public function testTheInstalledSpecTargetRecordsItsUpstreamCommit(): void + { + $upstream = $this->manifest()['upstream'] ?? []; + + $this->assertNotEmpty($upstream['repository'] ?? null); + $this->assertNotEmpty($upstream['ref'] ?? null); + $this->assertMatchesRegularExpression('/^[0-9a-f]{40}$/', (string) ($upstream['commit'] ?? '')); + } + + /** + * Located through the library's own runtime class rather than a hardcoded vendor path, so this + * still resolves wherever the package is installed. + * + * @return array + */ + private function manifest(): array + { + $runtime = (new \ReflectionClass(SpecObject::class))->getFileName(); + + $this->assertIsString($runtime); + + $path = dirname($runtime, 2) . '/spec.manifest.json'; + + $this->assertFileExists($path, 'The spec library ships no manifest to compare against.'); + + /** @var array $manifest */ + $manifest = json_decode((string) file_get_contents($path), true); + + return $manifest; + } +} diff --git a/Test/Unit/Controller/ApiControllerTest.php b/Test/Unit/Controller/ApiControllerTest.php index 9443419..b04cb41 100644 --- a/Test/Unit/Controller/ApiControllerTest.php +++ b/Test/Unit/Controller/ApiControllerTest.php @@ -14,12 +14,16 @@ use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageErrorInterfaceFactory; +use Magebit\UcpSpec\Api\UcpErrorInterface; use Magebit\UcpSpec\Data\Shopping\Types\MessageError; +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; use Magebit\UniversalCommerce\Controller\ApiController; +use Magebit\UniversalCommerce\Test\Unit\SchemaAssert; use Magebit\UniversalCommerce\Model\Config; use Magebit\UniversalCommerce\Model\IdempotencyHandler; -use Magebit\UniversalCommerce\Model\RequestClassBuilder; -use Magebit\UniversalCommerce\Model\Validation\RequestValidator; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use Magebit\AgenticCore\Model\Request\Hydrator; +use Magebit\AgenticCore\Model\Validation\RequestValidator; use Magento\Framework\App\Request\Http; use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory; @@ -28,6 +32,8 @@ class ApiControllerTest extends TestCase { + use SchemaAssert; + private const UUID = '3f2504e0-4f89-11d3-9a0c-0305e82c3301'; /** @@ -92,6 +98,44 @@ public function testBoundaryErrorsCarryTypeContentAndSeverity(): void $this->assertSame(MessageErrorInterface::SEVERITY_RECOVERABLE, $message->getSeverity()); } + /** + * The envelope is `types/error_response.json`, which 2026-04-08 introduced and which forbids + * additional properties — so the invented top-level `status` this module used to send is now a + * violation rather than a harmless extra. + * + * @return void + */ + public function testTheErrorEnvelopeMatchesTheSpecSchema(): void + { + $result = $this->controller(null)->errorBoundary(fn (): ResultJson => $this->fail('reached')); + + $this->assertMatchesSchema($this->encoded($result), 'shopping/types/error_response.json'); + } + + /** + * @return void + */ + public function testTheEnvelopeReportsErrorStatusInsideUcpMetadata(): void + { + $payload = $this->encoded($this->controller(null)->errorBoundary(fn (): ResultJson => $this->fail('reached'))); + + $this->assertFalse(property_exists($payload, 'status')); + $this->assertSame(UcpErrorInterface::STATUS_ERROR, $payload->ucp->status); + $this->assertSame(UniversalCommerceProtocolInterface::SPEC_VERSION, $payload->ucp->version); + } + + /** + * @param ResultJson $result + * @return object The payload as an agent would receive it, so nothing hides behind PHP objects + */ + private function encoded(ResultJson $result): object + { + /** @var object $decoded */ + $decoded = json_decode((string) json_encode($result->getData()), false, 512, JSON_THROW_ON_ERROR); + + return $decoded; + } + /** * @param ResultJson $result Response produced by the boundary * @return MessageErrorInterface @@ -113,7 +157,11 @@ private function firstMessage(ResultJson $result): MessageErrorInterface private function controller(?string $requestId, bool $required = true): ApiController { $request = $this->createMock(Http::class); - $request->method('getHeader')->willReturn($requestId ?? false); + $request->method('getHeader')->willReturnCallback( + static fn (string $name): string|false => $name === ApiController::HEADER_REQUEST_ID + ? ($requestId ?? false) + : false + ); $config = $this->createMock(Config::class); $config->method('isRequestIdRequired')->willReturn($required); @@ -128,11 +176,12 @@ private function controller(?string $requestId, bool $required = true): ApiContr $jsonFactory, $request, $this->createMock(RequestValidator::class), - $this->createMock(RequestClassBuilder::class), + $this->createMock(Hydrator::class), $config, $messageFactory, $this->createMock(IdempotencyHandler::class), - $this->createMock(LoggerInterface::class) + $this->createMock(LoggerInterface::class), + new VersionNegotiator() ) extends ApiController { /** * @return ResultJson diff --git a/Test/Unit/Controller/Testing/SimulateShippingTest.php b/Test/Unit/Controller/Testing/SimulateShippingTest.php new file mode 100644 index 0000000..8cf0805 --- /dev/null +++ b/Test/Unit/Controller/Testing/SimulateShippingTest.php @@ -0,0 +1,230 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Controller\Testing; + +use Magebit\UniversalCommerce\Controller\Testing\SimulateShipping; +use Magebit\UniversalCommerce\Model\Order\SessionOrderLookup; +use Magebit\UniversalCommerce\Model\Simulation\Secret; +use Magento\Framework\App\Request\Http; +use Magento\Framework\Controller\Result\Json as ResultJson; +use Magento\Framework\Controller\Result\JsonFactory; +use Magento\Sales\Api\ShipOrderInterface; +use Magento\Sales\Model\Order; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class SimulateShippingTest extends TestCase +{ + private const SECRET = 'super-secret-sim-key'; + private const CHECKOUT_ID = 'checkout_session_placeholder_0001'; + private const INCREMENT_ID = '000000123'; + private const ENTITY_ID = 42; + private const SHIPMENT_ID = 7; + + /** + * A store that put no secret in env.php must look as though the endpoint were never routed. + * + * @return void + */ + public function testTheEndpointIsInvisibleWithoutASecret(): void + { + $shipOrder = $this->createMock(ShipOrderInterface::class); + $shipOrder->expects($this->never())->method('execute'); + + $result = $this->controller(configured: null, sent: self::SECRET, shipOrder: $shipOrder)->execute(); + + $this->assertSame(404, $result->getHttpResponseCode()); + } + + /** + * @return void + */ + public function testAMissingSecretHeaderIsRefused(): void + { + $shipOrder = $this->createMock(ShipOrderInterface::class); + $shipOrder->expects($this->never())->method('execute'); + + $result = $this->controller(sent: null, shipOrder: $shipOrder)->execute(); + + $this->assertSame(403, $result->getHttpResponseCode()); + } + + /** + * @return void + */ + public function testAWrongSecretIsRefused(): void + { + $shipOrder = $this->createMock(ShipOrderInterface::class); + $shipOrder->expects($this->never())->method('execute'); + + $result = $this->controller(sent: 'for-sure-incorrect-secret', shipOrder: $shipOrder)->execute(); + + $this->assertSame(403, $result->getHttpResponseCode()); + } + + /** + * @return void + */ + public function testAnUnknownIdentifierIsNotFound(): void + { + $result = $this->controller(found: false)->execute(); + + $this->assertSame(404, $result->getHttpResponseCode()); + } + + /** + * Even with the right secret, this endpoint has no business shipping an order the protocol did not + * place — the storefront's orders included. Only the checkout session reaches an order, and those + * orders have none. + * + * @return void + */ + public function testAnOrderPlacedOutsideThisProtocolIsNotShipped(): void + { + $shipOrder = $this->createMock(ShipOrderInterface::class); + $shipOrder->expects($this->never())->method('execute'); + + $result = $this->controller(found: false, shipOrder: $shipOrder)->execute(); + + $this->assertSame(404, $result->getHttpResponseCode()); + } + + /** + * @return void + */ + public function testAnOrderThatCannotShipIsReportedAsAConflict(): void + { + $result = $this->controller(canShip: false)->execute(); + + $this->assertSame(409, $result->getHttpResponseCode()); + } + + /** + * @return void + */ + public function testAnAgentOrderIsShipped(): void + { + $shipOrder = $this->createMock(ShipOrderInterface::class); + $shipOrder->expects($this->once()) + ->method('execute') + ->with(self::ENTITY_ID) + ->willReturn(self::SHIPMENT_ID); + + $result = $this->controller(shipOrder: $shipOrder)->execute(); + + $this->assertSame(200, $result->getHttpResponseCode()); + $this->assertSame( + [ + 'order_id' => self::CHECKOUT_ID, + 'label' => self::INCREMENT_ID, + 'shipment_id' => self::SHIPMENT_ID, + ], + $result->getData() + ); + } + + /** + * @return void + */ + public function testAFailedShipmentIsReportedAsAServerError(): void + { + $shipOrder = $this->createMock(ShipOrderInterface::class); + $shipOrder->method('execute')->willThrowException(new \RuntimeException('no carrier')); + + $result = $this->controller(shipOrder: $shipOrder)->execute(); + + $this->assertSame(500, $result->getHttpResponseCode()); + } + + /** + * @param string|null $configured Secret env.php holds + * @param string|null $sent Secret the caller sent + * @param bool $found Whether an order of that increment id exists + * @param bool $canShip Whether the order is in a shippable state + * @param ShipOrderInterface|null $shipOrder + * @return SimulateShipping + */ + private function controller( + ?string $configured = self::SECRET, + ?string $sent = self::SECRET, + bool $found = true, + bool $canShip = true, + ?ShipOrderInterface $shipOrder = null + ): SimulateShipping { + $deploymentConfig = $this->createMock(\Magento\Framework\App\DeploymentConfig::class); + $deploymentConfig->method('get')->with(Secret::CONFIG_PATH)->willReturn($configured); + + $request = $this->createMock(Http::class); + $request->method('getHeader')->with(SimulateShipping::HEADER_SECRET)->willReturn($sent ?? false); + $request->method('getParam')->with('order_id')->willReturn(self::CHECKOUT_ID); + + $order = $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEntityId', 'canShip', 'getIncrementId']) + ->getMock(); + $order->method('getEntityId')->willReturn(self::ENTITY_ID); + $order->method('canShip')->willReturn($canShip); + $order->method('getIncrementId')->willReturn(self::INCREMENT_ID); + + $lookup = $this->createMock(SessionOrderLookup::class); + $lookup->method('find')->willReturn($found ? $order : null); + + $jsonFactory = $this->createMock(JsonFactory::class); + $jsonFactory->method('create')->willReturnCallback(fn (): ResultJson => $this->resultJson()); + + return new SimulateShipping( + $jsonFactory, + $request, + new Secret($deploymentConfig), + $lookup, + $shipOrder ?? $this->createMock(ShipOrderInterface::class), + $this->createMock(LoggerInterface::class) + ); + } + + /** + * A result that remembers what was set on it, so the status code can be asserted. + * + * @return ResultJson&MockObject + */ + private function resultJson(): ResultJson + { + $state = ['data' => null, 'code' => 200]; + + $json = $this->getMockBuilder(ResultJson::class) + ->disableOriginalConstructor() + ->onlyMethods(['setData', 'setHttpResponseCode']) + ->addMethods(['getData', 'getHttpResponseCode']) + ->getMock(); + + $json->method('setData')->willReturnCallback(function ($data) use (&$state, $json) { + $state['data'] = $data; + + return $json; + }); + $json->method('getData')->willReturnCallback(function () use (&$state) { + return $state['data']; + }); + $json->method('setHttpResponseCode')->willReturnCallback(function ($code) use (&$state, $json) { + $state['code'] = (int) $code; + + return $json; + }); + $json->method('getHttpResponseCode')->willReturnCallback(function () use (&$state): int { + return $state['code']; + }); + + return $json; + } +} diff --git a/Test/Unit/FixtureScrubber.php b/Test/Unit/FixtureScrubber.php index da35f68..d666690 100644 --- a/Test/Unit/FixtureScrubber.php +++ b/Test/Unit/FixtureScrubber.php @@ -39,14 +39,19 @@ class FixtureScrubber /** * Placeholders are chosen so re-running the scrubber is a no-op, and so that no placeholder * itself looks like the machine-specific value it replaced (e.g. the cache hash is not hex). - * - * @var array */ private const ENTITY_ID = '1000'; + /** + * @var array + */ + private const PATTERNS = [ // Media cache hash: rotates whenever image config changes. ['~/media/catalog/product/cache/[0-9a-f]{32}/~', '/media/catalog/product/cache/' . self::CACHE_HASH . '/'], + // Shipment tracking URL hash. The base64 arrives percent-encoded, so the escapes count as part + // of the hash — without them a tail of the local value survives. + ['~([?&]hash)=(?:[A-Za-z0-9+/=_-]|%[0-9A-Fa-f]{2})+~', '$1=' . self::NONCE], // Static content deploy version: changes on every setup:static-content:deploy. ['~/static/version\d+/~', '/static/' . self::STATIC_VERSION . '/'], // ISO-8601 / RFC 3339 timestamps. @@ -58,7 +63,11 @@ class FixtureScrubber /** * @var int */ - private int $sequence = 0; + /** + * Prefix marking a scrubbed id. It is not numeric, so a second pass leaves it alone and a placeholder + * can never be mistaken for the row id it replaced. + */ + public const ID_PREFIX = 'id-'; /** * Returns a copy of the payload with every environment-derived value replaced. @@ -72,8 +81,6 @@ class FixtureScrubber */ public function scrub(array|object $payload): array|object { - $this->sequence = 0; - return $this->scrubNode($payload); } @@ -90,10 +97,7 @@ private function scrubNode(mixed $node): mixed $result->$key = $this->scrubMember($key, $value); } - /** @var array $scrubbed */ - $scrubbed = $this->scrubGeneratedId((array) $result); - - return (object) $scrubbed; + return $result; } if (is_array($node)) { @@ -103,7 +107,7 @@ private function scrubNode(mixed $node): mixed $result[$key] = $this->scrubMember((string) $key, $value); } - return $this->scrubGeneratedId($result); + return $result; } return $node; @@ -116,6 +120,14 @@ private function scrubNode(mixed $node): mixed */ private function scrubMember(string $key, mixed $value): mixed { + // A list of bare id strings references the same rows the ids do, so it is mapped the same way. + if (is_array($value) && str_ends_with($key, '_ids')) { + return array_map( + fn (mixed $item): mixed => is_string($item) ? $this->scrubId($item) : $this->scrubMember($key, $item), + $value + ); + } + return match (true) { is_array($value), is_object($value) => $this->scrubNode($value), is_string($value) => $this->scrubValue($key, $value), @@ -139,6 +151,10 @@ private function scrubValue(string $key, string $value): string return self::SESSION_ID; } + if ($key === 'id' || str_ends_with($key, '_id')) { + return $this->scrubId($value); + } + $value = $this->scrubOrigin($value); foreach (self::PATTERNS as [$pattern, $replacement]) { @@ -168,17 +184,20 @@ static function (array $match): string { } /** - * Auto-increment database ids (quote items) become stable sequential placeholders. + * A database id becomes a placeholder derived from the id itself rather than from its position, so + * that every reference to it — in the same fixture or in another captured from the same run — scrubs + * to the same value. Anything not purely numeric, a SKU or a reverse-domain handler name, is left + * alone. * - * @param array $node - * @return array + * @param string $value + * @return string */ - private function scrubGeneratedId(array $node): array + private function scrubId(string $value): string { - if (isset($node['id']) && is_string($node['id']) && preg_match('~^\d+$~', $node['id']) === 1) { - $node['id'] = (string) (1000 + ++$this->sequence); + if (preg_match('~^\d+$~', $value) !== 1) { + return $value; } - return $node; + return self::ID_PREFIX . substr(hash('sha256', $value), 0, 8); } } diff --git a/Test/Unit/FixtureScrubberTest.php b/Test/Unit/FixtureScrubberTest.php index 0d3d933..34b27be 100644 --- a/Test/Unit/FixtureScrubberTest.php +++ b/Test/Unit/FixtureScrubberTest.php @@ -75,12 +75,91 @@ public function testReplacesGeneratedSessionIdToken(): void $this->assertSame(FixtureScrubber::SESSION_ID, $result['id']); } - public function testNumbersQuoteItemIdsSequentially(): void + /** + * Derived from the id rather than from its position, so a fixture captured later in the same run + * still agrees with this one about which row is which. + */ + public function testReplacesDatabaseIdsWithAPlaceholderDerivedFromTheId(): void { $result = $this->scrubber->scrub(['line_items' => [['id' => '18'], ['id' => '4271']]]); + $first = $result['line_items'][0]['id']; + $second = $result['line_items'][1]['id']; + + $this->assertStringStartsWith(FixtureScrubber::ID_PREFIX, $first); + $this->assertNotSame($first, $second); + $this->assertSame($first, (new FixtureScrubber())->scrub(['id' => '18'])['id']); + } + + /** + * The order schema references line items by id from expectations, events and adjustments. Scrubbing + * each occurrence independently produced a fixture whose references pointed at nothing, so the + * suite validated payloads the module could never emit. + */ + public function testTheSameIdScrubsToTheSamePlaceholderEverywhere(): void + { + $result = $this->scrubber->scrub([ + 'line_items' => [['id' => '110'], ['id' => '111']], + 'fulfillment' => [ + 'expectations' => [['line_items' => [['id' => '110', 'quantity' => 2]]]], + ], + 'adjustments' => [['line_items' => [['id' => '111', 'quantity' => -1]]]], + ]); + + $this->assertSame( + $result['line_items'][0]['id'], + $result['fulfillment']['expectations'][0]['line_items'][0]['id'] + ); + $this->assertSame( + $result['line_items'][1]['id'], + $result['adjustments'][0]['line_items'][0]['id'] + ); + $this->assertNotSame($result['line_items'][0]['id'], $result['line_items'][1]['id']); + } + + /** + * The checkout response references its line items through a list of bare id strings, which kept the + * real database ids while the line items themselves were renumbered. + */ + public function testIdListsAreScrubbedToMatchTheItemsTheyReference(): void + { + $result = $this->scrubber->scrub([ + 'line_items' => [['id' => '110']], + 'fulfillment' => [ + 'methods' => [['line_item_ids' => ['110'], 'selected_destination_id' => '170']], + ], + ]); + + $this->assertSame( + [$result['line_items'][0]['id']], + $result['fulfillment']['methods'][0]['line_item_ids'] + ); + $this->assertNotSame('170', $result['fulfillment']['methods'][0]['selected_destination_id']); + } + + /** + * The shipment tracking URL carries a hash of the local shipment row. + */ + public function testReplacesTheTrackingUrlHash(): void + { + $result = $this->scrubber->scrub([ + 'tracking_url' => 'https://shop.local/shipping/tracking/popup?hash=c2hpcF9pZDozOmNlNmMwM2M', + ]); + + $this->assertStringNotContainsString('c2hpcF9pZDozOmNlNmMwM2M', $result['tracking_url']); + } + + /** + * Magento percent-encodes the base64 padding, so the escapes belong to the hash. + * + * @return void + */ + public function testReplacesAPercentEncodedTrackingUrlHash(): void + { + $result = $this->scrubber->scrub([ + 'tracking_url' => 'https://shop.local/shipping/tracking/popup?hash=c2hpcF9pZDoxMQ%7E%7E', + ]); - $this->assertSame('1001', $result['line_items'][0]['id']); - $this->assertSame('1002', $result['line_items'][1]['id']); + $this->assertStringEndsWith('?hash=' . FixtureScrubber::NONCE, $result['tracking_url']); } public function testLeavesSkuLikeIdsAlone(): void diff --git a/Test/Unit/Model/Discovery/MerchantProfileBuilderTest.php b/Test/Unit/Model/Discovery/MerchantProfileBuilderTest.php new file mode 100644 index 0000000..583a41f --- /dev/null +++ b/Test/Unit/Model/Discovery/MerchantProfileBuilderTest.php @@ -0,0 +1,187 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Discovery; + +use Magebit\AgenticCore\Api\Data\SigningKeyInterface; +use Magebit\AgenticCore\Api\SigningKeyRepositoryInterface; +use Magebit\UcpSpec\Api\Discovery\ProfileSchemaInterfaceFactory; +use Magebit\UcpSpec\Api\Discovery\ProfileSchemaSigningKeyInterfaceFactory; +use Magebit\UcpSpec\Data\Discovery\ProfileSchemaSigningKey; +use Magebit\UcpSpec\Api\UcpBusinessSchemaInterfaceFactory; +use Magebit\UcpSpec\Data\Discovery\ProfileSchema; +use Magebit\UcpSpec\Data\UcpBusinessSchema; +use Magebit\UniversalCommerce\Model\Discovery\MerchantProfileBuilder; +use Magebit\UniversalCommerce\Model\Discovery\ServiceRegistry; +use PHPUnit\Framework\TestCase; + +class MerchantProfileBuilderTest extends TestCase +{ + private const JWK = [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => 'f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU', + 'y' => 'x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0', + 'kid' => 'merchant-2026', + 'use' => 'sig', + 'alg' => 'ES256', + ]; + + /** + * A verifier cannot check a signed webhook without the key, so publishing it is not optional. + * + * @return void + */ + public function testPublishesTheSigningKey(): void + { + $profile = $this->build([$this->key(self::JWK)]); + + $published = $profile->getSigningKeys(); + + $this->assertCount(1, $published); + $this->assertSame('merchant-2026', $published[0]->getKid()); + $this->assertSame('P-256', $published[0]->getCrv()); + $this->assertSame('ES256', $published[0]->getAlg()); + } + + /** + * Rotation publishes the new key alongside the retired one so signatures already in flight keep + * verifying through the grace window. + * + * @return void + */ + public function testPublishesEveryKeyAVerifierMayStillNeed(): void + { + $retired = ['kid' => 'merchant-2025'] + self::JWK; + $profile = $this->build([$this->key(self::JWK), $this->key($retired)]); + + $published = $profile->getSigningKeys(); + + $this->assertCount(2, $published); + $this->assertSame( + ['merchant-2026', 'merchant-2025'], + array_map(static fn ($jwk): string => $jwk->getKid(), $published) + ); + } + + /** + * @return void + */ + public function testNeverPublishesAPrivateHalf(): void + { + $profile = $this->build([$this->key(self::JWK)]); + + foreach ($profile->getSigningKeys() as $jwk) { + $this->assertArrayNotHasKey('d', $jwk->toArray()); + } + } + + /** + * A key row whose stored JWK cannot be read is skipped rather than published as an empty object, + * which a verifier would try to use and fail on. + * + * @return void + */ + public function testSkipsAKeyWithNoReadableJwk(): void + { + $profile = $this->build([$this->key([]), $this->key(self::JWK)]); + $published = $profile->getSigningKeys(); + + $this->assertCount(1, $published); + $this->assertSame('merchant-2026', $published[0]->getKid()); + } + + /** + * An agent fetches the profile before any webhook arrives, so the key has to exist by then. + * + * @return void + */ + public function testCreatesTheKeyIfTheProfileIsAskedForFirst(): void + { + $repository = $this->createMock(SigningKeyRepositoryInterface::class); + $repository->expects($this->once())->method('getSigningKey')->willReturn($this->key(self::JWK)); + $repository->method('getPublishableKeys')->willReturn([$this->key(self::JWK)]); + + $this->buildWith($repository); + } + + /** + * @param SigningKeyInterface[] $keys + * @return ProfileSchema + */ + private function build(array $keys): ProfileSchema + { + $profileFactory = $this->createMock(ProfileSchemaInterfaceFactory::class); + $profileFactory->method('create')->willReturnCallback( + static fn (array $args): ProfileSchema => new ProfileSchema($args['data']) + ); + + $ucpFactory = $this->createMock(UcpBusinessSchemaInterfaceFactory::class); + $ucpFactory->method('create')->willReturnCallback( + static fn (array $args): UcpBusinessSchema => new UcpBusinessSchema($args['data']) + ); + + $registry = $this->createMock(ServiceRegistry::class); + $registry->method('getServices')->willReturn([]); + + $repository = $this->createMock(SigningKeyRepositoryInterface::class); + $repository->method('getPublishableKeys')->willReturn($keys); + $repository->method('getSigningKey')->willReturn($this->key(self::JWK)); + + return $this->buildWith($repository); + } + + /** + * @param SigningKeyRepositoryInterface $repository + * @return ProfileSchema + */ + private function buildWith(SigningKeyRepositoryInterface $repository): ProfileSchema + { + $profileFactory = $this->createMock(ProfileSchemaInterfaceFactory::class); + $profileFactory->method('create')->willReturnCallback( + static fn (array $args): ProfileSchema => new ProfileSchema($args['data']) + ); + + $ucpFactory = $this->createMock(UcpBusinessSchemaInterfaceFactory::class); + $ucpFactory->method('create')->willReturnCallback( + static fn (array $args): UcpBusinessSchema => new UcpBusinessSchema($args['data']) + ); + + $registry = $this->createMock(ServiceRegistry::class); + $registry->method('getServices')->willReturn([]); + + $jwkFactory = $this->createMock(ProfileSchemaSigningKeyInterfaceFactory::class); + $jwkFactory->method('create')->willReturnCallback( + static fn (array $args): ProfileSchemaSigningKey => new ProfileSchemaSigningKey($args['data']) + ); + + return (new MerchantProfileBuilder( + $profileFactory, + $ucpFactory, + $registry, + $repository, + $jwkFactory + ))->build(); + } + + /** + * @param array $jwk + * @return SigningKeyInterface + */ + private function key(array $jwk): SigningKeyInterface + { + $key = $this->createMock(SigningKeyInterface::class); + $key->method('getPublicJwk')->willReturn($jwk); + + return $key; + } +} diff --git a/Test/Unit/Model/IdempotencyHandlerTest.php b/Test/Unit/Model/IdempotencyHandlerTest.php index c563909..a2db50e 100644 --- a/Test/Unit/Model/IdempotencyHandlerTest.php +++ b/Test/Unit/Model/IdempotencyHandlerTest.php @@ -12,16 +12,19 @@ namespace Magebit\UniversalCommerce\Test\Unit\Model; +use Magebit\AgenticCore\Api\Data\IdempotencyRecordInterface; +use Magebit\AgenticCore\Model\Idempotency\ClaimOutcome; +use Magebit\AgenticCore\Model\Idempotency\ClaimResult; +use Magebit\AgenticCore\Model\Idempotency\Coordinator; +use Magebit\AgenticCore\Model\Idempotency\Gate; +use Magebit\AgenticCore\Model\Idempotency\RequestHasher; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; -use Magebit\UniversalCommerce\Api\Data\IdempotencyKeyInterface; -use Magebit\UniversalCommerce\Api\Data\IdempotencyKeyInterfaceFactory; -use Magebit\UniversalCommerce\Api\IdempotencyKeyRepositoryInterface; use Magebit\UniversalCommerce\Model\IdempotencyHandler; use Magento\Framework\App\Request\Http; use Magento\Framework\Controller\Result\Json as ResultJson; use Magento\Framework\Controller\Result\JsonFactory; -use Magento\Framework\Exception\NoSuchEntityException; -use Magento\Framework\Stdlib\DateTime\DateTime; +use Magento\Framework\Encryption\EncryptorInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -30,153 +33,230 @@ class IdempotencyHandlerTest extends TestCase private const KEY = 'test-key'; private const BODY = '{"line_items":[]}'; - /** @var IdempotencyKeyRepositoryInterface&MockObject */ - private IdempotencyKeyRepositoryInterface $repository; - - /** @var JsonFactory&MockObject */ - private JsonFactory $resultJsonFactory; + /** @var Coordinator&MockObject */ + private Coordinator $coordinator; /** @var IdempotencyHandler */ private IdempotencyHandler $handler; + /** + * Data the handler wrote onto the result, so the envelope can be asserted rather than its type. + * + * @var array + */ + private array $written = []; + + /** + * Message payloads the spec factory was asked to build. + * + * @var array> + */ + private array $messages = []; + /** * @return void */ protected function setUp(): void { - $this->repository = $this->createMock(IdempotencyKeyRepositoryInterface::class); - $this->resultJsonFactory = $this->createMock(JsonFactory::class); + $this->written = []; + $this->messages = []; - $this->resultJsonFactory->method('create')->willReturnCallback( - fn (): ResultJson => $this->createMock(ResultJson::class) + $this->coordinator = $this->createMock(Coordinator::class); + + $result = $this->createMock(ResultJson::class); + $result->method('setData')->willReturnCallback( + function ($data) use ($result): ResultJson { + $this->written['data'] = $data; + return $result; + } + ); + $result->method('setJsonData')->willReturnCallback( + function (string $data) use ($result): ResultJson { + $this->written['json'] = $data; + return $result; + } ); + $result->method('setHttpResponseCode')->willReturnCallback( + function ($code) use ($result): ResultJson { + $this->written['code'] = $code; + return $result; + } + ); + $result->method('setHeader')->willReturnCallback( + function (string $name, $value) use ($result): ResultJson { + $this->written['header:' . $name] = (string) $value; + return $result; + } + ); + + $resultJsonFactory = $this->createMock(JsonFactory::class); + $resultJsonFactory->method('create')->willReturn($result); - $dateTime = $this->createMock(DateTime::class); - $dateTime->method('gmtDate')->willReturn('2026-01-01 00:00:00'); + $messageFactory = $this->createMock(MessageInterfaceFactory::class); + $messageFactory->method('create')->willReturnCallback( + function (array $arguments): MessageInterface { + /** @var array $data */ + $data = $arguments['data'] ?? []; + $this->messages[] = $data; - $this->handler = new IdempotencyHandler( - $this->repository, - $this->createMock(IdempotencyKeyInterfaceFactory::class), - $this->resultJsonFactory, - $this->createMock(MessageInterfaceFactory::class), - $dateTime + return $this->createMock(MessageInterface::class); + } ); + + $gate = new Gate($this->coordinator, new RequestHasher(), $this->encryptor()); + + $this->handler = new IdempotencyHandler($gate, $resultJsonFactory, $messageFactory); } /** - * @return void + * Stands in for the framework's encryptor, including its `::` envelope and + * its habit of returning garbage rather than failing when handed a value it never encrypted. + * + * @return EncryptorInterface */ - public function testUnclaimedKeyLetsTheCallerProceed(): void + private function encryptor(): EncryptorInterface { - $this->repository->method('claim')->willReturn(true); + $encryptor = $this->createMock(EncryptorInterface::class); + $encryptor->method('encrypt')->willReturnCallback( + static fn (?string $value): string => '0:3:' . base64_encode((string) $value) + ); + $encryptor->method('decrypt')->willReturnCallback( + static function (?string $value): string { + $payload = substr((string) $value, strlen('0:3:')); + $decoded = base64_decode($payload, true); - $this->assertNull($this->handler->handle($this->request('POST', self::KEY))); + return $decoded === false ? "\x00garbage" : $decoded; + } + ); + + return $encryptor; } /** * @return void */ - public function testSafeMethodIsNeverReplayed(): void + public function testAClaimedKeyLetsTheCallerProceed(): void { - $this->repository->expects($this->never())->method('claim'); - $this->repository->expects($this->never())->method('getByKey'); + $this->coordinator->method('claim')->willReturn(new ClaimResult(ClaimOutcome::Claimed)); - $this->assertNull($this->handler->handle($this->request('GET', self::KEY))); + $this->assertNull($this->handler->handle($this->request('POST', self::KEY))); } /** + * Replaying a safe method would hide current state, so the coordinator is never consulted. + * * @return void */ - public function testMissingKeyLetsTheCallerProceed(): void + public function testASafeMethodIsNeverReplayed(): void { - $this->repository->expects($this->never())->method('claim'); + $this->coordinator->expects($this->never())->method('claim'); - $this->assertNull($this->handler->handle($this->request('POST', null))); + $this->assertNull($this->handler->handle($this->request('GET', self::KEY))); } /** * @return void */ - public function testSameKeyAndBodyReplaysStoredResponse(): void + public function testAMissingKeyLetsTheCallerProceed(): void { - $this->repository->method('claim')->willReturn(false); - $this->repository->method('getByKey')->willReturn( - $this->storedKey($this->currentRequestHash(), 201) - ); + $this->coordinator->expects($this->never())->method('claim'); - $this->assertInstanceOf(ResultJson::class, $this->handler->handle($this->request('POST', self::KEY))); + $this->assertNull($this->handler->handle($this->request('POST', null))); } /** - * A different body under the same key must conflict, never replay the wrong response. - * * @return void */ - public function testSameKeyDifferentBodyDoesNotReplay(): void + public function testAnInFlightClaimIsRefusedWithRetryAfter(): void { - $stored = $this->storedKey('a-different-hash', 201); - $stored->expects($this->never())->method('getResponseBody'); - - $this->repository->method('claim')->willReturn(false); - $this->repository->method('getByKey')->willReturn($stored); + $this->coordinator->method('claim')->willReturn(new ClaimResult(ClaimOutcome::InFlight)); $this->assertInstanceOf(ResultJson::class, $this->handler->handle($this->request('POST', self::KEY))); + $this->assertSame(409, $this->written['code']); + $this->assertSame('idempotency_key_in_flight', $this->messages[0]['code']); + $this->assertSame('1', $this->written['header:Retry-After']); } /** - * A claim purged between the failed insert and the read must not block the caller. - * * @return void */ - public function testVanishedClaimLetsTheCallerProceed(): void + public function testAConflictIsRefusedWithoutRetryAfter(): void { - $this->repository->method('claim')->willReturn(false); - $this->repository->method('getByKey')->willThrowException(new NoSuchEntityException()); + $this->coordinator->method('claim')->willReturn(new ClaimResult(ClaimOutcome::Conflict)); - $this->assertNull($this->handler->handle($this->request('POST', self::KEY))); + $this->assertInstanceOf(ResultJson::class, $this->handler->handle($this->request('POST', self::KEY))); + $this->assertSame(409, $this->written['code']); + $this->assertSame('idempotency_key_reuse', $this->messages[0]['code']); + $this->assertArrayNotHasKey('header:Retry-After', $this->written); } /** + * The stored body is replayed byte for byte under its stored status. + * * @return void */ - public function testInFlightClaimIsNotExecutedTwice(): void + public function testAStoredResponseIsReplayedVerbatim(): void { - $this->repository->method('claim')->willReturn(false); - $this->repository->method('getByKey')->willReturn( - $this->storedKey($this->currentRequestHash(), null) - ); - $this->repository->method('reclaimAbandoned')->willReturn(false); + $record = $this->createMock(IdempotencyRecordInterface::class); + $record->method('getResponseBody')->willReturn('0:3:' . base64_encode('{"id":"abc"}')); + $record->method('getResponseStatus')->willReturn(201); + + $this->coordinator->method('claim')->willReturn(new ClaimResult(ClaimOutcome::Replay, $record)); $this->assertInstanceOf(ResultJson::class, $this->handler->handle($this->request('POST', self::KEY))); + $this->assertSame('{"id":"abc"}', $this->written['json']); + $this->assertSame(201, $this->written['code']); } /** + * Rows written before this module encrypted its bodies are still inside their TTL on an upgraded + * install. Decrypting one speculatively would replay binary garbage to the agent. + * * @return void */ - public function testAbandonedClaimIsTakenOver(): void + public function testAPlaintextRowWrittenBeforeEncryptionStillReplays(): void { - $this->repository->method('claim')->willReturn(false); - $this->repository->method('getByKey')->willReturn( - $this->storedKey($this->currentRequestHash(), null) - ); - $this->repository->method('reclaimAbandoned')->willReturn(true); + $record = $this->createMock(IdempotencyRecordInterface::class); + $record->method('getResponseBody')->willReturn('{"id":"legacy"}'); + $record->method('getResponseStatus')->willReturn(200); - $this->assertNull($this->handler->handle($this->request('POST', self::KEY))); + $this->coordinator->method('claim')->willReturn(new ClaimResult(ClaimOutcome::Replay, $record)); + + $this->handler->handle($this->request('POST', self::KEY)); + + $this->assertSame('{"id":"legacy"}', $this->written['json']); + $this->assertSame(200, $this->written['code']); } /** - * @param string $requestHash - * @param int|null $status - * @return IdempotencyKeyInterface&MockObject + * @return void */ - private function storedKey(string $requestHash, ?int $status): IdempotencyKeyInterface + public function testStoringAResponseHandsTheBodyToTheCoordinator(): void { - $stored = $this->createMock(IdempotencyKeyInterface::class); - $stored->method('getRequestHash')->willReturn($requestHash); - $stored->method('getResponseStatus')->willReturn($status); - $stored->method('getResponseBody')->willReturn('{"id":"abc"}'); - - return $stored; + $this->coordinator->expects($this->once()) + ->method('storeResponse') + ->with( + IdempotencyHandler::SCOPE, + self::KEY, + $this->anything(), + 201, + '0:3:' . base64_encode('{"id":"abc"}') + ) + ->willReturn($this->createMock(IdempotencyRecordInterface::class)); + + $this->handler->storeResponse( + $this->request('POST', self::KEY), + new class implements \JsonSerializable { + /** + * @return array + */ + public function jsonSerialize(): array + { + return ['id' => 'abc']; + } + }, + 201 + ); } /** @@ -191,20 +271,8 @@ private function request(string $method, ?string $key): Http $request->method('getHeader')->willReturn($key ?? false); $request->method('getContent')->willReturn(self::BODY); $request->method('getPathInfo')->willReturn('/ucp/shopping/checkout-sessions'); + $request->method('getQuery')->willReturn([]); return $request; } - - /** - * Mirrors the handler's own hashing so a "same request" case really matches. - * - * @return string - */ - private function currentRequestHash(): string - { - $reflection = new \ReflectionMethod(IdempotencyHandler::class, 'hashRequest'); - $reflection->setAccessible(true); - - return $reflection->invoke($this->handler, $this->request('POST', self::KEY)); - } } diff --git a/Test/Unit/Model/Order/AdjustmentRecorderTest.php b/Test/Unit/Model/Order/AdjustmentRecorderTest.php new file mode 100644 index 0000000..4623c0c --- /dev/null +++ b/Test/Unit/Model/Order/AdjustmentRecorderTest.php @@ -0,0 +1,139 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Order; + +use Magebit\AgenticCore\Model\Order\Note; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentInterface; +use Magebit\UcpSpec\Data\Shopping\Types\Adjustment; +use Magebit\UniversalCommerce\Api\OrderAdjustmentRepositoryInterface; +use Magebit\UniversalCommerce\Model\Order\AdjustmentRecorder; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToAdjustments; +use Magento\Sales\Model\Order; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class AdjustmentRecorderTest extends TestCase +{ + private const ENTITY_ID = 42; + + private OrderAdjustmentRepositoryInterface&MockObject $repository; + + private OrderToAdjustments&MockObject $adjustmentsConverter; + + private Note&MockObject $orderNote; + + private AdjustmentRecorder $recorder; + + /** + * @return void + */ + protected function setUp(): void + { + $this->repository = $this->createMock(OrderAdjustmentRepositoryInterface::class); + $this->adjustmentsConverter = $this->createMock(OrderToAdjustments::class); + $this->orderNote = $this->createMock(Note::class); + $this->recorder = new AdjustmentRecorder( + $this->repository, + $this->adjustmentsConverter, + $this->orderNote + ); + } + + /** + * @return void + */ + public function testANewAdjustmentIsRecordedAndNotedOnTheOrder(): void + { + $this->adjustmentsConverter->method('knownIds')->willReturn([]); + $this->repository->expects($this->once()) + ->method('record') + ->with(self::ENTITY_ID, 'adj_1', $this->stringContains('"type":"refund"')); + $this->orderNote->expects($this->once()) + ->method('add') + ->with($this->anything(), $this->stringContains('refund')); + + $recorded = $this->recorder->record($this->order(), [$this->adjustment('adj_1')]); + + $this->assertSame(1, $recorded); + } + + /** + * A repeated request must not add a second copy, and an agent's request for a refund is not the + * refund: whatever the store already accounts for stands. + * + * @return void + */ + public function testAnAdjustmentTheStoreAlreadyKnowsIsLeftAlone(): void + { + $this->adjustmentsConverter->method('knownIds')->willReturn(['adj_1']); + $this->repository->expects($this->never())->method('record'); + $this->orderNote->expects($this->never())->method('add'); + + $this->assertSame(0, $this->recorder->record($this->order(), [$this->adjustment('adj_1')])); + } + + /** + * @return void + */ + public function testTheDescriptionIsCarriedIntoTheNote(): void + { + $this->adjustmentsConverter->method('knownIds')->willReturn([]); + $this->orderNote->expects($this->once()) + ->method('add') + ->with($this->anything(), $this->stringContains('Defective item')); + + $adjustment = $this->adjustment('adj_1'); + $adjustment->setDescription('Defective item'); + + $this->recorder->record($this->order(), [$adjustment]); + } + + /** + * @return void + */ + public function testNothingSubmittedMeansNothingRecorded(): void + { + $this->repository->expects($this->never())->method('record'); + + $this->assertSame(0, $this->recorder->record($this->order(), [])); + } + + /** + * @param string $id + * @return AdjustmentInterface + */ + private function adjustment(string $id): AdjustmentInterface + { + $adjustment = new Adjustment(); + $adjustment->setId($id); + $adjustment->setType('refund'); + $adjustment->setOccurredAt('2026-08-31T12:00:00Z'); + $adjustment->setStatus(AdjustmentInterface::STATUS_PENDING); + + return $adjustment; + } + + /** + * @return Order&MockObject + */ + private function order(): Order&MockObject + { + $order = $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEntityId']) + ->getMock(); + $order->method('getEntityId')->willReturn(self::ENTITY_ID); + + return $order; + } +} diff --git a/Test/Unit/Model/Order/SessionOrderLookupTest.php b/Test/Unit/Model/Order/SessionOrderLookupTest.php new file mode 100644 index 0000000..9a65562 --- /dev/null +++ b/Test/Unit/Model/Order/SessionOrderLookupTest.php @@ -0,0 +1,115 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Order; + +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Order\SessionOrderLookup; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Sales\Api\OrderRepositoryInterface; +use Magento\Sales\Model\Order; +use PHPUnit\Framework\TestCase; + +class SessionOrderLookupTest extends TestCase +{ + private const CHECKOUT_ID = 'checkout_session_placeholder_0001'; + private const ORDER_ID = 42; + + /** + * @return void + */ + public function testTheOrderTheSessionPlacedIsFound(): void + { + $order = $this->order(); + + $this->assertSame($order, $this->lookup(self::ORDER_ID, $order)->find(self::CHECKOUT_ID)); + } + + /** + * @return void + */ + public function testASessionThatPlacedNoOrderFindsNothing(): void + { + $this->assertNull($this->lookup(null, $this->order())->find(self::CHECKOUT_ID)); + } + + /** + * @return void + */ + public function testAnOrderThatIsGoneFindsNothing(): void + { + $this->assertNull($this->lookup(self::ORDER_ID, null)->find(self::CHECKOUT_ID)); + } + + /** + * An empty path segment is not an identifier, so nothing is looked up for it. + * + * @return void + */ + public function testAnEmptyIdentifierIsNotLookedUp(): void + { + $links = $this->createMock(OrderLinkRepositoryInterface::class); + $links->expects($this->never())->method('findOrderId'); + + $lookup = new SessionOrderLookup($links, $this->createMock(OrderRepositoryInterface::class)); + + $this->assertNull($lookup->find('')); + } + + /** + * @return void + */ + public function testOnlyThisProtocolsLinksAreRead(): void + { + $links = $this->createMock(OrderLinkRepositoryInterface::class); + $links->expects($this->once()) + ->method('findOrderId') + ->with(IdempotencyHandler::SCOPE, self::CHECKOUT_ID) + ->willReturn(null); + + $lookup = new SessionOrderLookup($links, $this->createMock(OrderRepositoryInterface::class)); + + $this->assertNull($lookup->find(self::CHECKOUT_ID)); + } + + /** + * @param int|null $linkedOrderId Order the session placed, or null when it placed none + * @param Order|null $order The order the repository still holds, or null when it is gone + * @return SessionOrderLookup + */ + private function lookup(?int $linkedOrderId, ?Order $order): SessionOrderLookup + { + $links = $this->createMock(OrderLinkRepositoryInterface::class); + $links->method('findOrderId')->willReturn($linkedOrderId); + + $orders = $this->createMock(OrderRepositoryInterface::class); + + if ($order === null) { + $orders->method('get')->willThrowException(new NoSuchEntityException(__('No such order.'))); + } else { + $orders->method('get')->willReturn($order); + } + + return new SessionOrderLookup($links, $orders); + } + + /** + * @return Order + */ + private function order(): Order + { + return $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/Test/Unit/Model/Protocol/UndeclaredExtensionsTest.php b/Test/Unit/Model/Protocol/UndeclaredExtensionsTest.php new file mode 100644 index 0000000..27e1544 --- /dev/null +++ b/Test/Unit/Model/Protocol/UndeclaredExtensionsTest.php @@ -0,0 +1,119 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Protocol; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\Types\Message; +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutResponseInterface; +use Magebit\UniversalCommerce\Api\ServiceInterface; +use Magebit\UniversalCommerce\Model\Discovery\ServiceRegistry; +use Magebit\UniversalCommerce\Model\Protocol\UndeclaredExtensions; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class UndeclaredExtensionsTest extends TestCase +{ + private const EXTENSIONS = ['ap2' => 'dev.ucp.shopping.ap2_mandate']; + + /** + * @return void + */ + public function testAnExtensionTheStoreDoesNotDeclareIsReported(): void + { + $warnings = $this->reporter(['dev.ucp.shopping.checkout'])->report(['ap2' => ['x' => 'y']]); + + $this->assertCount(1, $warnings); + $this->assertSame('warning', $warnings[0]->getType()); + $this->assertSame(UndeclaredExtensions::CODE_NOT_ACCEPTED, $warnings[0]->getCode()); + $this->assertSame('$.ap2', $warnings[0]->getPath()); + $this->assertStringContainsString('dev.ucp.shopping.ap2_mandate', (string) $warnings[0]->getContent()); + } + + /** + * @return void + */ + public function testAnExtensionTheStoreDeclaresIsNotReported(): void + { + $declared = ['dev.ucp.shopping.checkout', 'dev.ucp.shopping.ap2_mandate']; + + $this->assertSame([], $this->reporter($declared)->report(['ap2' => ['x' => 'y']])); + } + + /** + * @return void + */ + public function testARequestThatCarriesNoExtensionIsNotReportedOn(): void + { + $this->assertSame([], $this->reporter(['dev.ucp.shopping.checkout'])->report(['payment' => []])); + } + + /** + * The specification leaves request objects open, so an unknown field is not by itself something to + * complain about — only the extensions this store knows of and does not implement. + * + * @return void + */ + public function testAnUnknownFieldIsNotMistakenForAnExtension(): void + { + $this->assertSame([], $this->reporter(['dev.ucp.shopping.checkout'])->report(['whatever' => 1])); + } + + /** + * @return void + */ + public function testTheWarningIsAddedBesideAnyMessagesTheResponseAlreadyCarries(): void + { + $existing = $this->createMock(MessageInterface::class); + $response = $this->createMock(CheckoutResponseInterface::class); + $response->method('getMessages')->willReturn([$existing]); + $response->expects($this->once()) + ->method('setMessages') + ->with($this->callback(static function (array $messages) use ($existing): bool { + return count($messages) === 2 && $messages[0] === $existing; + })); + + $this->reporter(['dev.ucp.shopping.checkout'])->annotate($response, ['ap2' => []]); + } + + /** + * @return void + */ + public function testAResponseIsLeftAloneWhenThereIsNothingToSay(): void + { + $response = $this->createMock(CheckoutResponseInterface::class); + $response->expects($this->never())->method('setMessages'); + + $this->reporter(['dev.ucp.shopping.checkout'])->annotate($response, ['payment' => []]); + } + + /** + * @param string[] $declaredCapabilities + * @return UndeclaredExtensions + */ + private function reporter(array $declaredCapabilities): UndeclaredExtensions + { + $service = $this->createMock(ServiceInterface::class); + $service->method('getCapabilities')->willReturn(array_fill_keys($declaredCapabilities, [])); + + $registry = $this->createMock(ServiceRegistry::class); + $registry->method('getService')->willReturn($service); + + $messageFactory = $this->createMock(MessageInterfaceFactory::class); + $messageFactory->method('create')->willReturnCallback( + static fn (array $arguments = []): Message => new Message($arguments['data'] ?? []) + ); + + return new UndeclaredExtensions($registry, $messageFactory, self::EXTENSIONS); + } +} diff --git a/Test/Unit/Model/Protocol/VersionNegotiatorTest.php b/Test/Unit/Model/Protocol/VersionNegotiatorTest.php new file mode 100644 index 0000000..8d8a129 --- /dev/null +++ b/Test/Unit/Model/Protocol/VersionNegotiatorTest.php @@ -0,0 +1,94 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Protocol; + +use Magebit\UniversalCommerce\Api\UniversalCommerceProtocolInterface; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\Protocol\VersionNegotiator; +use PHPUnit\Framework\TestCase; + +class VersionNegotiatorTest extends TestCase +{ + private VersionNegotiator $negotiator; + + /** + * @return void + */ + protected function setUp(): void + { + $this->negotiator = new VersionNegotiator(); + } + + /** + * @param string|null $header + * @param string|null $expected + * @return void + * @dataProvider headerProvider + */ + public function testTheVersionIsReadOffTheAgentHeader(?string $header, ?string $expected): void + { + $this->assertSame($expected, $this->negotiator->requested($header)); + } + + /** + * @return array + */ + public static function headerProvider(): array + { + return [ + 'no header' => [null, null], + 'empty header' => ['', null], + 'profile only' => ['profile="https://agent.example/profile"', null], + 'quoted version' => ['profile="https://agent.example/p"; version="2026-04-08"', '2026-04-08'], + 'unquoted version' => ['profile="https://agent.example/p"; version=2026-04-08', '2026-04-08'], + 'version first' => ['version="2026-04-08"; profile="https://agent.example/p"', '2026-04-08'], + ]; + } + + /** + * @return void + */ + public function testAnAgentThatNamesNoVersionIsServed(): void + { + $this->expectNotToPerformAssertions(); + + $this->negotiator->assertSupported('profile="https://agent.example/profile"'); + } + + /** + * @return void + */ + public function testTheStoresOwnVersionIsServed(): void + { + $this->expectNotToPerformAssertions(); + + $this->negotiator->assertSupported( + 'profile="x"; version="' . UniversalCommerceProtocolInterface::SPEC_VERSION . '"' + ); + } + + /** + * @return void + */ + public function testAnyOtherVersionIsRefusedAsUnprocessable(): void + { + try { + $this->negotiator->assertSupported('profile="x"; version="2099-01-01"'); + $this->fail('An unsupported version must be refused.'); + } catch (UcpException $exception) { + $this->assertSame(422, $exception->getStatusCode()); + $this->assertSame(VersionNegotiator::CODE_UNSUPPORTED_VERSION, $exception->getErrorCode()); + $this->assertStringContainsString('2099-01-01', $exception->getMessage()); + } + } +} diff --git a/Test/Unit/Model/Seed/ConformanceFixturesTest.php b/Test/Unit/Model/Seed/ConformanceFixturesTest.php new file mode 100644 index 0000000..650fd4e --- /dev/null +++ b/Test/Unit/Model/Seed/ConformanceFixturesTest.php @@ -0,0 +1,114 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Seed; + +use Magebit\UniversalCommerce\Model\Seed\ConformanceFixtures; +use Magento\SalesRule\Model\Rule; +use PHPUnit\Framework\TestCase; + +/** + * The conformance run asserts exact totals against these fixtures, so the definitions are pinned + * here — a silent edit to a price or a discount would show up as an unexplained conformance failure. + */ +class ConformanceFixturesTest extends TestCase +{ + /** + * @return void + */ + public function testEverySkuIsSeededExactlyOnce(): void + { + $skus = array_column(ConformanceFixtures::products(), 'sku'); + + $this->assertSame([ + ConformanceFixtures::SKU_SIMPLE, + ConformanceFixtures::SKU_SECOND, + ConformanceFixtures::SKU_OUT_OF_STOCK, + ], $skus); + $this->assertSame($skus, array_unique($skus)); + } + + /** + * The out-of-stock path is a required conformance scenario, so exactly one fixture supplies it. + * + * @return void + */ + public function testExactlyOneProductIsOutOfStock(): void + { + $outOfStock = array_values(array_filter( + ConformanceFixtures::products(), + static fn (array $p): bool => $p['in_stock'] === false + )); + + $this->assertCount(1, $outOfStock); + $this->assertSame(ConformanceFixtures::SKU_OUT_OF_STOCK, $outOfStock[0]['sku']); + $this->assertSame(0, $outOfStock[0]['qty']); + } + + /** + * @return void + */ + public function testInStockProductsCarryStock(): void + { + foreach (ConformanceFixtures::products() as $product) { + if ($product['in_stock']) { + $this->assertGreaterThan(0, $product['qty'], $product['sku'] . ' must have stock'); + } + } + } + + /** + * @return void + */ + public function testPricesAreFixedSoTotalsCanBeAsserted(): void + { + $prices = array_column(ConformanceFixtures::products(), 'price', 'sku'); + + $this->assertSame(20.00, $prices[ConformanceFixtures::SKU_SIMPLE]); + $this->assertSame(35.50, $prices[ConformanceFixtures::SKU_SECOND]); + $this->assertSame(15.00, $prices[ConformanceFixtures::SKU_OUT_OF_STOCK]); + } + + /** + * @return void + */ + public function testTheThreeExpectedCouponsAreDefined(): void + { + $codes = array_column(ConformanceFixtures::rules(), 'code'); + + $this->assertSame([ + ConformanceFixtures::COUPON_PERCENT_10, + ConformanceFixtures::COUPON_PERCENT_20, + ConformanceFixtures::COUPON_FIXED_500, + ], $codes); + } + + /** + * `FIXED500` is 500 minor units, which Magento expresses as 5.00 major units — the conversion + * that makes this fixture easy to get wrong. + * + * @return void + */ + public function testDiscountActionsAndAmountsMatchTheirNames(): void + { + $rules = array_column(ConformanceFixtures::rules(), null, 'code'); + + $this->assertSame(Rule::BY_PERCENT_ACTION, $rules[ConformanceFixtures::COUPON_PERCENT_10]['action']); + $this->assertSame(10.0, $rules[ConformanceFixtures::COUPON_PERCENT_10]['amount']); + + $this->assertSame(Rule::BY_PERCENT_ACTION, $rules[ConformanceFixtures::COUPON_PERCENT_20]['action']); + $this->assertSame(20.0, $rules[ConformanceFixtures::COUPON_PERCENT_20]['amount']); + + $this->assertSame(Rule::CART_FIXED_ACTION, $rules[ConformanceFixtures::COUPON_FIXED_500]['action']); + $this->assertSame(5.00, $rules[ConformanceFixtures::COUPON_FIXED_500]['amount']); + } +} diff --git a/Test/Unit/Model/Service/Shopping/AgentProfileParserTest.php b/Test/Unit/Model/Service/Shopping/AgentProfileParserTest.php new file mode 100644 index 0000000..8542a43 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/AgentProfileParserTest.php @@ -0,0 +1,216 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\OrderResponsePlatformSchemaInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\OrderResponsePlatformSchema; +use Magebit\UniversalCommerce\Model\Service\Shopping\AgentProfileParser; +use Magebit\UniversalCommerce\Model\Service\Shopping\ProfileUrlValidator; +use Magebit\UniversalCommerce\Model\Service\Shopping\TrustedProfileOrigins; +use Magento\Framework\App\CacheInterface; +use Magento\Framework\HTTP\Client\CurlFactory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class AgentProfileParserTest extends TestCase +{ + private const WEBHOOK_URL = 'https://agent.test/hook'; + private const PUBLIC_WEBHOOK_URL = 'https://93.184.216.34/hook'; + private const INTERNAL_WEBHOOK_URL = 'https://169.254.169.254/latest/meta-data/'; + + /** @var AgentProfileParser */ + private AgentProfileParser $parser; + + /** + * @return void + */ + protected function setUp(): void + { + $this->parser = $this->parserWith($this->createMock(ProfileUrlValidator::class)); + } + + /** + * `capabilities` is an object keyed by reverse-domain name whose values are arrays of entries, so the + * name is the key and no entry carries one. Looking for a `name` field inside each value could never + * match a conformant platform profile. + * + * @return void + */ + public function testTheWebhookUrlIsReadFromTheKeyedCapabilityRegistry(): void + { + $this->assertSame(self::WEBHOOK_URL, $this->parser->parseWebhookUrl($this->header([ + 'ucp' => [ + 'version' => '2026-04-08', + 'capabilities' => [ + 'dev.ucp.shopping.order' => [ + ['version' => '2026-04-08', 'config' => ['webhook_url' => self::WEBHOOK_URL]], + ], + ], + ], + ]))); + } + + /** + * A platform may declare several entries for one capability; the first that configures a URL wins. + * + * @return void + */ + public function testTheFirstConfiguredEntryWins(): void + { + $this->assertSame(self::WEBHOOK_URL, $this->parser->parseWebhookUrl($this->header([ + 'ucp' => [ + 'capabilities' => [ + 'dev.ucp.shopping.order' => [ + ['version' => '2026-04-08'], + ['version' => '2026-04-08', 'config' => ['webhook_url' => self::WEBHOOK_URL]], + ], + ], + ], + ]))); + } + + /** + * Most platforms declare no order capability, which is not an error. + * + * @return void + */ + public function testAProfileWithoutTheOrderCapabilityYieldsNothing(): void + { + $this->assertNull($this->parser->parseWebhookUrl($this->header([ + 'ucp' => ['capabilities' => ['dev.ucp.shopping.checkout' => [['version' => '2026-04-08']]]], + ]))); + } + + /** + * @return void + */ + public function testNoHeaderYieldsNothing(): void + { + $this->assertNull($this->parser->parseWebhookUrl(null)); + $this->assertNull($this->parser->parseWebhookUrl('profile="not-a-uri"')); + } + + /** + * The generated field is required-typed, so an absent URL has to be left unset rather than written + * as null. + * + * @return void + */ + public function testAnAbsentUrlLeavesTheFieldUnset(): void + { + $schema = $this->parser->parse($this->header(['ucp' => ['capabilities' => []]])); + + $this->assertFalse($schema->has(OrderResponsePlatformSchema::KEY_WEBHOOK_URL)); + } + + /** + * @return void + */ + public function testAPublicWebhookUrlSurvivesTheGuard(): void + { + $parser = $this->parserWith(new ProfileUrlValidator($this->untrustingOrigins())); + + $this->assertSame( + self::PUBLIC_WEBHOOK_URL, + $parser->parseWebhookUrl($this->headerWithWebhookUrl(self::PUBLIC_WEBHOOK_URL)) + ); + } + + /** + * Without this the agent could name an address inside our own network and have the store post to it. + * + * @return void + */ + public function testAWebhookUrlPointingInsideTheNetworkIsDropped(): void + { + $parser = $this->parserWith(new ProfileUrlValidator($this->untrustingOrigins())); + + $this->assertNull($parser->parseWebhookUrl($this->headerWithWebhookUrl(self::INTERNAL_WEBHOOK_URL))); + } + + /** + * A rejected URL must not turn a working checkout into an error. + * + * @return void + */ + public function testARejectedWebhookUrlLeavesTheFieldUnsetInsteadOfRaising(): void + { + $parser = $this->parserWith(new ProfileUrlValidator($this->untrustingOrigins())); + + $schema = $parser->parse($this->headerWithWebhookUrl(self::INTERNAL_WEBHOOK_URL)); + + $this->assertFalse($schema->has(OrderResponsePlatformSchema::KEY_WEBHOOK_URL)); + } + + /** + * @param ProfileUrlValidator $urlValidator + * @return AgentProfileParser + */ + private function parserWith(ProfileUrlValidator $urlValidator): AgentProfileParser + { + $factory = $this->createMock(OrderResponsePlatformSchemaInterfaceFactory::class); + $factory->method('create') + ->willReturnCallback(fn (): OrderResponsePlatformSchema => new OrderResponsePlatformSchema()); + + $cache = $this->createMock(CacheInterface::class); + $cache->method('load')->willReturn(false); + + return new AgentProfileParser( + $factory, + $this->createMock(CurlFactory::class), + $cache, + $this->createMock(LoggerInterface::class), + $urlValidator + ); + } + + /** + * @return TrustedProfileOrigins A list that names nothing, so only the routability rules decide + */ + private function untrustingOrigins(): TrustedProfileOrigins + { + $origins = $this->createMock(TrustedProfileOrigins::class); + $origins->method('trusts')->willReturn(false); + + return $origins; + } + + /** + * @param string $webhookUrl + * @return string + */ + private function headerWithWebhookUrl(string $webhookUrl): string + { + return $this->header([ + 'ucp' => [ + 'capabilities' => [ + 'dev.ucp.shopping.order' => [ + ['version' => '2026-04-08', 'config' => ['webhook_url' => $webhookUrl]], + ], + ], + ], + ]); + } + + /** + * @param array $profile + * @return string A UCP-Agent header carrying the profile inline, which the parser supports + */ + private function header(array $profile): string + { + return sprintf( + 'profile="data:application/json;base64,%s"', + base64_encode((string) json_encode($profile)) + ); + } +} diff --git a/Test/Unit/Model/Service/Shopping/CartHandlerTest.php b/Test/Unit/Model/Service/Shopping/CartHandlerTest.php new file mode 100644 index 0000000..f1f04ff --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/CartHandlerTest.php @@ -0,0 +1,169 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping; + +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutCreateRequestInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutUpdateRequestInterface; +use Magebit\UniversalCommerce\Api\Service\Shopping\RestHandlerInterface; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\Service\Shopping\CartHandler; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToCartResponse; +use Magebit\UcpSpec\Data\Shopping\CartResponse; +use Magento\Quote\Model\Quote; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class CartHandlerTest extends TestCase +{ + private const CART_ID = 'cart_placeholder_0001'; + + /** + * @var RestHandlerInterface&MockObject + */ + private RestHandlerInterface $restHandler; + + /** + * @return void + */ + protected function setUp(): void + { + $this->restHandler = $this->createMock(RestHandlerInterface::class); + } + + /** + * @return void + */ + public function testCreateReturnsTheNewCart(): void + { + $this->restHandler->method('createCheckout') + ->willReturn(new \Magebit\UniversalCommerce\Model\Spec\Schemas\Shopping\CheckoutResponse( + ['id' => self::CART_ID] + )); + + $created = $this->handler()->createCart($this->createMock(CheckoutCreateRequestInterface::class)); + + $this->assertSame(self::CART_ID, $created->getId()); + } + + /** + * @return void + */ + public function testGetReturnsTheCart(): void + { + $this->assertSame(self::CART_ID, $this->handler()->getCart(self::CART_ID)->getId()); + } + + /** + * A cart's status is binary — it exists or it is not found — so a canceled cart is reported gone + * rather than as a cart in a canceled state. + * + * @return void + */ + public function testReadingACanceledCartIsNotFound(): void + { + $handler = $this->handler(isActive: false); + + $this->expectException(UcpException::class); + $this->expectExceptionMessage('Cart not found'); + + $handler->getCart(self::CART_ID); + } + + /** + * @return void + */ + public function testTheNotFoundStatusCodeIs404(): void + { + try { + $this->handler(isActive: false)->getCart(self::CART_ID); + $this->fail('Expected a not-found failure.'); + } catch (UcpException $exception) { + $this->assertSame(404, $exception->statusCode); + $this->assertSame('not_found', $exception->errorCode); + } + } + + /** + * @return void + */ + public function testACanceledCartCannotBeUpdated(): void + { + $this->restHandler->expects($this->never())->method('updateCheckout'); + + $this->expectException(UcpException::class); + + $this->handler(isActive: false)->updateCart( + self::CART_ID, + $this->createMock(CheckoutUpdateRequestInterface::class) + ); + } + + /** + * @return void + */ + public function testACanceledCartCannotBeCanceledAgain(): void + { + $this->restHandler->expects($this->never())->method('cancelCheckout'); + + $this->expectException(UcpException::class); + + $this->handler(isActive: false)->cancelCart(self::CART_ID); + } + + /** + * The spec has cancel return the state before deletion, so the cancelling call still answers with a + * body even though later reads report the cart gone. + * + * @return void + */ + public function testCancelStillReturnsTheCart(): void + { + $this->restHandler->expects($this->once())->method('cancelCheckout'); + + $this->assertSame(self::CART_ID, $this->handler()->cancelCart(self::CART_ID)->getId()); + } + + /** + * A cart has no complete operation at all. + * + * @return void + */ + public function testACartIsNeverCompleted(): void + { + $this->restHandler->expects($this->never())->method('completeCheckout'); + + $this->handler()->getCart(self::CART_ID); + } + + /** + * @param bool $isActive + * @return CartHandler + */ + private function handler(bool $isActive = true): CartHandler + { + $quote = $this->getMockBuilder(Quote::class) + ->disableOriginalConstructor() + ->onlyMethods(['getIsActive']) + ->getMock(); + $quote->method('getIsActive')->willReturn($isActive); + + $this->restHandler->method('getCartByMaskedId')->willReturn($quote); + + $converter = $this->createMock(QuoteToCartResponse::class); + $converter->method('convert')->willReturnCallback( + static fn (mixed $q, string $cartId): CartResponse => new CartResponse(['id' => $cartId]) + ); + + return new CartHandler($this->restHandler, $converter); + } +} diff --git a/Test/Unit/Model/Service/Shopping/CatalogHandlerTest.php b/Test/Unit/Model/Service/Shopping/CatalogHandlerTest.php new file mode 100644 index 0000000..38d3f67 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/CatalogHandlerTest.php @@ -0,0 +1,824 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\CatalogLookupDetailProductInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\CatalogLookupGetProductResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\CatalogLookupLookupResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\CatalogSearchSearchResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\InputCorrelationInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\PaginationResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\ProductInterface; +use Magebit\UcpSpec\Api\UcpResponseCatalogSchemaInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\CatalogLookupDetailProduct; +use Magebit\UcpSpec\Data\Shopping\CatalogLookupGetProductResponse; +use Magebit\UcpSpec\Data\Shopping\CatalogLookupLookupResponse; +use Magebit\UcpSpec\Data\Shopping\CatalogSearchSearchResponse; +use Magebit\UcpSpec\Data\Shopping\Types\InputCorrelation; +use Magebit\UcpSpec\Data\Shopping\Types\PaginationResponse; +use Magebit\UcpSpec\Data\Shopping\Types\Product; +use Magebit\UcpSpec\Data\Shopping\Types\Variant; +use Magebit\UcpSpec\Data\UcpResponseCatalogSchema; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\Discovery\ServiceRegistry; +use Magebit\UniversalCommerce\Model\Service\Shopping\CatalogHandler; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\ProductToUcpProduct; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Catalog\Model\Product as MagentoProduct; +use Magento\Catalog\Model\Product\Attribute\Source\Status; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\CatalogInventory\Model\ResourceModel\Stock\Status as StockStatus; +use Magento\ConfigurableProduct\Model\Product\Type\Configurable; +use Magento\ConfigurableProduct\Model\ResourceModel\Product\Type\Configurable as ConfigurableResource; +use Magento\Framework\DB\Adapter\AdapterInterface; +use Magento\Framework\DB\Select; +use Magento\Framework\EntityManager\EntityMetadataInterface; +use Magento\Framework\EntityManager\MetadataPool; +use Magento\Store\Model\Store; +use Magento\Store\Model\StoreManagerInterface; +use PHPUnit\Framework\TestCase; + +class CatalogHandlerTest extends TestCase +{ + private const STORE_ID = 3; + private const VISIBLE_IDS = [2, 3, 4]; + + /** + * Every filter the handler put on a collection, as [attribute, condition]. + * + * @var array + */ + private array $filters = []; + + /** + * Every page the handler asked the database for, as [size, offset]. + * + * @var array + */ + private array $pages = []; + + /** + * @var array> + */ + private array $visibilities = []; + + /** + * @var int[] + */ + private array $storeFilters = []; + + /** + * Every list of ids the handler narrowed a collection down to. + * + * @var array + */ + private array $idFilters = []; + + private bool $distinctAsked = false; + + /** + * The rows the fake collection hands back. + * + * @var MagentoProduct[] + */ + private array $items = []; + + private int $collections = 0; + + /** + * The variants the fake child collection hands back. + * + * @var MagentoProduct[] + */ + private array $children = []; + + /** + * Which variant belongs to which product, as the link table holds it. + * + * @var array + */ + private array $links = []; + + private int $stockJoins = 0; + + private bool $stockFiltered = false; + + private int $variantQueries = 0; + + private int $total = 0; + + /** + * @return void + */ + protected function setUp(): void + { + $this->filters = []; + $this->pages = []; + $this->visibilities = []; + $this->storeFilters = []; + $this->items = []; + $this->idFilters = []; + $this->distinctAsked = false; + $this->collections = 0; + $this->children = []; + $this->links = []; + $this->stockJoins = 0; + $this->stockFiltered = false; + $this->variantQueries = 0; + $this->total = 0; + } + + /** + * The endpoint is public, so a huge list of ids must not turn into a huge number of product reads. + * + * @return void + */ + public function testLookupAsksForNoMoreIdsThanTheMaximum(): void + { + $ids = []; + + for ($i = 0; $i < 500; $i++) { + $ids[] = 'ucp-' . $i; + } + + $this->handler()->lookup($ids); + + $this->assertCount(CatalogHandler::MAX_LIMIT, $this->skusAsked()); + } + + /** + * The whole batch is one query, not one query per id. + * + * @return void + */ + public function testLookupReadsTheWholeBatchWithOneQuery(): void + { + $this->handler()->lookup(['ucp-one', 'ucp-two', 'ucp-three']); + + $this->assertSame(1, $this->collections); + $this->assertSame(['ucp-one', 'ucp-two', 'ucp-three'], $this->skusAsked()); + } + + /** + * @return void + */ + public function testAnIdThatMatchesNothingIsLeftOutOfTheResponse(): void + { + $this->items = [$this->product('ucp-one')]; + + $products = $this->handler()->lookup(['ucp-one', 'ucp-gone'])->getProducts(); + + $this->assertCount(1, $products); + $this->assertSame('ucp-one', $products[0]->getId()); + } + + /** + * @return void + */ + public function testLookupReadsOnlyProductsTheStorefrontShows(): void + { + $this->handler()->lookup(['ucp-one']); + + $this->assertContains(['status', ['eq' => Status::STATUS_ENABLED]], $this->filters); + $this->assertSame([self::VISIBLE_IDS], $this->visibilities); + $this->assertSame([self::STORE_ID], $this->storeFilters); + } + + /** + * A SKU the storefront hides must read as missing, the same way search never offers it. + * + * @return void + */ + public function testAHiddenSkuIsReportedAsNotFound(): void + { + $this->expectException(UcpException::class); + $this->expectExceptionMessage('Product not found: ucp-hidden.'); + + $this->handler()->getProduct('ucp-hidden'); + } + + /** + * @return void + */ + public function testSearchReadsOnlyProductsTheStorefrontShows(): void + { + $this->handler()->search('shoes'); + + $this->assertContains(['status', ['eq' => Status::STATUS_ENABLED]], $this->filters); + $this->assertSame([self::VISIBLE_IDS], $this->visibilities); + $this->assertSame([self::STORE_ID], $this->storeFilters); + } + + /** + * An empty query is allowed and matches the whole catalogue, so no text filter is added at all. + * + * @return void + */ + public function testAnEmptySearchQueryAddsNoTextFilter(): void + { + $this->handler()->search(null); + $this->handler()->search(' '); + + $this->assertFalse($this->hasTextFilter()); + } + + /** + * @return void + */ + public function testASearchQueryFiltersOnNameAndSku(): void + { + $this->handler()->search('shoes'); + + $this->assertTrue($this->hasTextFilter()); + } + + /** + * The page is cut by the database. The cursor offset is any number, not a multiple of the size. + * + * @return void + */ + public function testSearchAsksTheDatabaseForOnePage(): void + { + $this->handler()->search(null, 5, 'offset:7'); + + $this->assertSame([[5, 7]], $this->pages); + } + + /** + * @return void + */ + public function testSearchNeverAsksForMoreThanTheMaximumPageSize(): void + { + $this->handler()->search(null, 5000); + + $this->assertSame([[CatalogHandler::MAX_LIMIT, 0]], $this->pages); + } + + /** + * @return void + */ + public function testTheTotalIsTheCountedNumberOfProducts(): void + { + $this->total = 137; + + $pagination = $this->handler()->search(null, 20)->getPagination(); + + $this->assertSame(137, $pagination->getTotalCount()); + $this->assertTrue($pagination->getHasNextPage()); + $this->assertSame('offset:20', $pagination->getCursor()); + } + + /** + * The text filter joins an attribute table, which can list one product on several rows. The page + * is read as distinct ids and the products fetched by those, so a page holds the number of + * products asked for rather than however many rows the join produced. + * + * @return void + */ + public function testThePageIsReadAsDistinctProductIds(): void + { + $this->total = 2; + $this->items = [$this->product('ucp-one'), $this->product('ucp-two')]; + + $this->handler()->search('shoes', 20); + + $this->assertTrue($this->distinctAsked); + $this->assertSame([[1, 2]], $this->idFilters); + } + + /** + * @return void + */ + public function testTheLastPageHasNoCursor(): void + { + $this->total = 2; + $this->items = [$this->product('ucp-one'), $this->product('ucp-two')]; + + $pagination = $this->handler()->search(null, 20)->getPagination(); + + $this->assertFalse($pagination->getHasNextPage()); + $this->assertNull($pagination->getCursor()); + } + + /** + * Every variant on the page comes from one query, not one per product. A page of fifty products + * used to mean a query for each configurable product on it. + * + * @return void + */ + public function testEveryVariantOnThePageIsReadWithOneQuery(): void + { + $this->items = [$this->configurable('ucp-one', 11), $this->configurable('ucp-two', 22)]; + $this->children = [$this->variant('ucp-one-s', 101), $this->variant('ucp-two-s', 201)]; + $this->links = [$this->link(11, 101), $this->link(22, 201)]; + + $this->handler()->search(null); + + $this->assertSame(1, $this->variantQueries); + } + + /** + * The variants all arrive together, so each one has to end up under the product it belongs to. + * + * @return void + */ + public function testAVariantIsListedUnderTheProductItBelongsTo(): void + { + $this->items = [$this->configurable('ucp-one', 11), $this->configurable('ucp-two', 22)]; + $this->children = [ + $this->variant('ucp-one-s', 101), + $this->variant('ucp-two-s', 201), + $this->variant('ucp-one-m', 102), + ]; + $this->links = [$this->link(11, 101), $this->link(22, 201), $this->link(11, 102)]; + + $products = $this->handler()->search(null)->getProducts(); + + $this->assertSame(['ucp-one-s', 'ucp-one-m'], $this->variantSkusOf($products[0])); + $this->assertSame(['ucp-two-s'], $this->variantSkusOf($products[1])); + } + + /** + * Magento lets one simple product be a variant of two configurable products. Reading the page + * through Magento's own variant collection threw on that, because the collection cannot hold the + * same product twice. + * + * @return void + */ + public function testAVariantSharedByTwoProductsIsListedUnderBoth(): void + { + $this->items = [$this->configurable('ucp-one', 11), $this->configurable('ucp-two', 22)]; + $this->children = [$this->variant('ucp-shared', 101)]; + $this->links = [$this->link(11, 101), $this->link(22, 101)]; + + $products = $this->handler()->search(null)->getProducts(); + + $this->assertSame(['ucp-shared'], $this->variantSkusOf($products[0])); + $this->assertSame(['ucp-shared'], $this->variantSkusOf($products[1])); + } + + /** + * A variant the query left out, because it is out of stock or needs options chosen, is not + * offered under its product either. + * + * @return void + */ + public function testAVariantTheQueryLeftOutIsNotOffered(): void + { + $this->items = [$this->configurable('ucp-one', 11)]; + $this->children = [$this->variant('ucp-one-s', 101)]; + $this->links = [$this->link(11, 101), $this->link(11, 102)]; + + $products = $this->handler()->search(null)->getProducts(); + + $this->assertSame(['ucp-one-s'], $this->variantSkusOf($products[0])); + } + + /** + * A product with no variants of its own must not pick up another product's. + * + * @return void + */ + public function testAProductWithoutVariantsIsLeftWithItsOwn(): void + { + $this->items = [$this->configurable('ucp-one', 11), $this->product('ucp-plain')]; + $this->children = [$this->variant('ucp-one-s', 101)]; + $this->links = [$this->link(11, 101)]; + + $products = $this->handler()->search(null)->getProducts(); + + $this->assertSame(['ucp-one-s'], $this->variantSkusOf($products[0])); + $this->assertSame(['ucp-plain'], $this->variantSkusOf($products[1])); + } + + /** + * Nothing is configurable, so there is no variant query to make. + * + * @return void + */ + public function testAPageOfPlainProductsAsksForNoVariants(): void + { + $this->items = [$this->product('ucp-one'), $this->product('ucp-two')]; + + $this->handler()->search(null); + + $this->assertSame(0, $this->variantQueries); + } + + /** + * Stock rides along on the query that reads the page, rather than being asked for product by + * product, and out-of-stock products are kept rather than filtered away. + * + * @return void + */ + public function testStockIsReadWithThePageRatherThanPerProduct(): void + { + $this->items = [$this->product('ucp-one'), $this->product('ucp-two')]; + + $this->handler()->search(null); + + $this->assertSame(1, $this->stockJoins); + $this->assertFalse($this->stockFiltered); + } + + /** + * @return string[] + */ + private function skusAsked(): array + { + foreach ($this->filters as [$attribute, $condition]) { + if ($attribute === 'sku' && is_array($condition) && isset($condition['in'])) { + return (array) $condition['in']; + } + } + + return []; + } + + /** + * The name-or-sku filter is the only one passed as a list of conditions. + * + * @return bool + */ + private function hasTextFilter(): bool + { + foreach ($this->filters as [$attribute]) { + if (is_array($attribute)) { + return true; + } + } + + return false; + } + + /** + * @param string $sku + * @param string $type + * @return MagentoProduct + */ + private function product(string $sku, string $type = 'simple'): MagentoProduct + { + $product = $this->getMockBuilder(MagentoProduct::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSku', 'getTypeId', 'getProductUrl']) + ->getMock(); + $product->method('getSku')->willReturn($sku); + $product->method('getTypeId')->willReturn($type); + $product->method('getProductUrl')->willReturn('https://example.com/' . $sku); + + return $product; + } + + /** + * @param string $sku + * @param int $id + * @return MagentoProduct + */ + private function configurable(string $sku, int $id): MagentoProduct + { + $product = $this->product($sku, Configurable::TYPE_CODE); + $product->setData('entity_id', $id); + + return $product; + } + + /** + * @param string $sku + * @param int $id + * @return MagentoProduct + */ + private function variant(string $sku, int $id): MagentoProduct + { + $product = $this->product($sku); + $product->setData('entity_id', $id); + $product->setData('id', $id); + + return $product; + } + + /** + * One row of the link table. + * + * @param int $parentId + * @param int $variantId + * @return array{parent_id: int, product_id: int} + */ + private function link(int $parentId, int $variantId): array + { + return ['parent_id' => $parentId, 'product_id' => $variantId]; + } + + /** + * @param ProductInterface $product + * @return string[] + */ + private function variantSkusOf(ProductInterface $product): array + { + return array_map( + static fn (mixed $variant): string => (string) $variant->getSku(), + $product->getVariants() + ); + } + + /** + * @return CatalogHandler + */ + private function handler(): CatalogHandler + { + $collectionFactory = $this->createMock(CollectionFactory::class); + $collectionFactory->method('create')->willReturnCallback( + fn (): ProductCollection => $this->collection() + ); + + $store = $this->createMock(Store::class); + $store->method('getId')->willReturn(self::STORE_ID); + $store->method('getCurrentCurrencyCode')->willReturn('EUR'); + + $storeManager = $this->createMock(StoreManagerInterface::class); + $storeManager->method('getStore')->willReturn($store); + + $visibility = $this->createMock(Visibility::class); + $visibility->method('getVisibleInSiteIds')->willReturn(self::VISIBLE_IDS); + + $imageHelper = $this->createMock(ImageHelper::class); + $imageHelper->method('init')->willReturnSelf(); + $imageHelper->method('getUrl')->willReturn('https://example.com/image.jpg'); + + return new CatalogHandler( + $collectionFactory, + $this->converter(), + $this->factoryFor(CatalogSearchSearchResponseInterfaceFactory::class, CatalogSearchSearchResponse::class), + $this->factoryFor(CatalogLookupLookupResponseInterfaceFactory::class, CatalogLookupLookupResponse::class), + $this->factoryFor( + CatalogLookupGetProductResponseInterfaceFactory::class, + CatalogLookupGetProductResponse::class + ), + $this->factoryFor(CatalogLookupDetailProductInterfaceFactory::class, CatalogLookupDetailProduct::class), + $this->factoryFor(PaginationResponseInterfaceFactory::class, PaginationResponse::class), + $this->factoryFor(UcpResponseCatalogSchemaInterfaceFactory::class, UcpResponseCatalogSchema::class), + $this->createMock(ServiceRegistry::class), + $storeManager, + $imageHelper, + $visibility, + $this->factoryFor(InputCorrelationInterfaceFactory::class, InputCorrelation::class), + $this->configurableResource(), + $this->stockStatus(), + $this->metadataPool() + ); + } + + /** + * A stand-in for the link table, which says which variant belongs to which product. + * + * @return ConfigurableResource + */ + private function configurableResource(): ConfigurableResource + { + $connection = $this->createMock(AdapterInterface::class); + $connection->method('select')->willReturnCallback(fn (): Select => $this->linkSelect()); + $connection->method('fetchAll')->willReturnCallback(fn (): array => $this->links); + + $resource = $this->createMock(ConfigurableResource::class); + $resource->method('getConnection')->willReturn($connection); + $resource->method('getMainTable')->willReturn('catalog_product_super_link'); + + return $resource; + } + + /** + * @return Select + */ + private function linkSelect(): Select + { + $select = $this->createMock(Select::class); + $select->method('from')->willReturnSelf(); + $select->method('where')->willReturnSelf(); + + return $select; + } + + /** + * @return StockStatus + */ + private function stockStatus(): StockStatus + { + $status = $this->createMock(StockStatus::class); + $status->method('addStockDataToCollection')->willReturnCallback( + function (mixed $collection, mixed $filter) use (&$status): mixed { + $this->stockJoins++; + $this->stockFiltered = $this->stockFiltered || (bool) $filter; + + return $collection; + } + ); + + return $status; + } + + /** + * @return MetadataPool + */ + private function metadataPool(): MetadataPool + { + $metadata = $this->createMock(EntityMetadataInterface::class); + $metadata->method('getLinkField')->willReturn('entity_id'); + + $pool = $this->createMock(MetadataPool::class); + $pool->method('getMetadata')->willReturn($metadata); + + return $pool; + } + + /** + * @return ProductToUcpProduct + */ + private function converter(): ProductToUcpProduct + { + $converter = $this->createMock(ProductToUcpProduct::class); + $converter->method('convert')->willReturnCallback( + static function (MagentoProduct $product, string $currencyCode, array $children = []): ProductInterface { + $sources = $children === [] ? [$product] : $children; + $variants = []; + + foreach ($sources as $source) { + $sku = (string) $source->getSku(); + $variants[] = new Variant(['id' => $sku, 'sku' => $sku]); + } + + return new Product(['id' => (string) $product->getSku(), 'variants' => $variants]); + } + ); + + return $converter; + } + + /** + * A stand-in for the product collection: it records what the handler filtered, paged and ordered by, + * and hands back the rows the test set up. + * + * @return ProductCollection + */ + private function collection(): ProductCollection + { + $this->collections++; + + $collection = $this->getMockBuilder(ProductCollection::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'addAttributeToSelect', + 'addAttributeToFilter', + 'setVisibility', + 'addStoreFilter', + 'setFlag', + 'getSelect', + 'getConnection', + 'getAllIds', + 'getIterator', + 'getItems', + 'addIdFilter', + 'addUrlRewrite', + 'addFilterByRequiredOptions', + 'setStoreId', + ]) + ->getMock(); + + $collection->method('addUrlRewrite')->willReturnSelf(); + $collection->method('setStoreId')->willReturnSelf(); + + // Only the variant query filters by required options, which is how this stand-in knows which + // of the two queries it is standing in for. + $isVariantQuery = false; + $collection->method('addFilterByRequiredOptions')->willReturnCallback( + function () use ($collection, &$isVariantQuery): ProductCollection { + $isVariantQuery = true; + $this->variantQueries++; + + return $collection; + } + ); + $rows = function () use (&$isVariantQuery): array { + return $isVariantQuery ? $this->children : $this->items; + }; + + // Reading every id and paging in PHP is the thing being fixed, so it must not come back. + $collection->expects($this->never())->method('getAllIds'); + + $collection->method('getSelect')->willReturn($this->select()); + $collection->method('getConnection')->willReturn($this->connection()); + $collection->method('addIdFilter')->willReturnCallback( + function (mixed $ids, bool $exclude = false) use ($collection): ProductCollection { + $this->idFilters[] = $ids; + + return $collection; + } + ); + $collection->method('getIterator')->willReturnCallback( + fn (): \ArrayIterator => new \ArrayIterator($rows()) + ); + $collection->method('getItems')->willReturnCallback($rows); + $collection->method('addAttributeToFilter')->willReturnCallback( + function (mixed $attribute, mixed $condition = null) use ($collection): ProductCollection { + $this->filters[] = [$attribute, $condition]; + + return $collection; + } + ); + $collection->method('setVisibility')->willReturnCallback( + function (array $visibility) use ($collection): ProductCollection { + $this->visibilities[] = $visibility; + + return $collection; + } + ); + $collection->method('addStoreFilter')->willReturnCallback( + function (mixed $store = null) use ($collection): ProductCollection { + $this->storeFilters[] = (int) $store; + + return $collection; + } + ); + + return $collection; + } + + /** + * @return Select + */ + private function select(): Select + { + $select = $this->createMock(Select::class); + $select->method('reset')->willReturnSelf(); + $select->method('columns')->willReturnSelf(); + $select->method('order')->willReturnSelf(); + $select->method('distinct')->willReturnCallback( + function (bool $flag = true) use ($select): Select { + $this->distinctAsked = $this->distinctAsked || $flag; + + return $select; + } + ); + $select->method('limit')->willReturnCallback( + function (mixed $count = null, mixed $offset = null) use ($select): Select { + $this->pages[] = [$count, $offset]; + + return $select; + } + ); + + return $select; + } + + /** + * @return string[] One id per row the fake collection holds + */ + private function pageIdRows(): array + { + $ids = []; + + for ($position = 1; $position <= count($this->items); $position++) { + $ids[] = (string) $position; + } + + return $ids; + } + + /** + * @return AdapterInterface + */ + private function connection(): AdapterInterface + { + $connection = $this->createMock(AdapterInterface::class); + $connection->method('fetchOne')->willReturnCallback(fn (): string => (string) $this->total); + // The page is read as a list of ids, one for every row the fake collection holds. + $connection->method('fetchCol')->willReturnCallback(fn (): array => $this->pageIdRows()); + + return $connection; + } + + /** + * @param class-string $factoryClass + * @param class-string $concrete + * @return mixed + */ + private function factoryFor(string $factoryClass, string $concrete): mixed + { + $factory = $this->createMock($factoryClass); + $factory->method('create')->willReturnCallback( + static fn (array $args = []): object => new $concrete($args['data'] ?? []) + ); + + return $factory; + } +} diff --git a/Test/Unit/Model/Service/Shopping/CheckoutDataProcessorTest.php b/Test/Unit/Model/Service/Shopping/CheckoutDataProcessorTest.php index 4b9e304..8daf8fc 100644 --- a/Test/Unit/Model/Service/Shopping/CheckoutDataProcessorTest.php +++ b/Test/Unit/Model/Service/Shopping/CheckoutDataProcessorTest.php @@ -18,14 +18,20 @@ use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentRequestInterface; use Magebit\UcpSpec\Api\Shopping\Types\ItemCreateRequestInterface; use Magebit\UcpSpec\Api\Shopping\Types\LineItemCreateRequestInterface; +use Magebit\UcpSpec\Api\Shopping\DiscountResponseDiscountsObjectInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +use Magebit\AgenticCore\Model\Buyer\BuyerWriter; +use Magebit\AgenticCore\Model\Quote\AddressWriter; +use Magebit\AgenticCore\Model\Quote\RegionResolver; +use Magebit\AgenticCore\Model\Quote\LineItemOutcome; +use Magebit\AgenticCore\Model\Quote\LineItemResult; +use Magebit\AgenticCore\Model\Quote\LineItemWriter; +use Magebit\AgenticCore\Model\Quote\PersonalInformationCopier; +use Magebit\AgenticCore\Model\Quote\ShippingMethodWriter; use Magebit\UniversalCommerce\Model\Service\Shopping\AgentProfileParser; use Magebit\UniversalCommerce\Model\Service\Shopping\CheckoutDataProcessor; -use Magento\Catalog\Api\ProductRepositoryInterface; -use Magento\Catalog\Model\Product; use Magento\Framework\App\Request\Http; -use Magento\Framework\Exception\NoSuchEntityException; use Magento\Quote\Api\GuestCouponManagementInterface; use Magento\Quote\Model\Quote; use Magento\Quote\Model\Quote\Address; @@ -42,9 +48,29 @@ class CheckoutDataProcessorTest extends TestCase private array $createdMessages = []; /** - * @var ProductRepositoryInterface&MockObject + * @var LineItemWriter&MockObject */ - private ProductRepositoryInterface $productRepository; + private LineItemWriter $lineItemWriter; + + /** + * @var ShippingMethodWriter&MockObject + */ + private ShippingMethodWriter $shippingMethodWriter; + + /** + * @var AgentProfileParser&MockObject + */ + private AgentProfileParser $agentProfileParser; + + /** + * @var Http&MockObject + */ + private Http $httpRequest; + + /** + * @var GuestCouponManagementInterface&MockObject + */ + private GuestCouponManagementInterface $couponManagement; /** * @var CheckoutDataProcessor @@ -57,7 +83,8 @@ class CheckoutDataProcessorTest extends TestCase protected function setUp(): void { $this->createdMessages = []; - $this->productRepository = $this->createMock(ProductRepositoryInterface::class); + $this->lineItemWriter = $this->createMock(LineItemWriter::class); + $this->shippingMethodWriter = $this->createMock(ShippingMethodWriter::class); $messageFactory = $this->createMock(MessageInterfaceFactory::class); $messageFactory->method('create')->willReturnCallback(function (array $arguments) { @@ -65,12 +92,20 @@ protected function setUp(): void return $this->createMock(MessageInterface::class); }); + $this->agentProfileParser = $this->createMock(AgentProfileParser::class); + $this->httpRequest = $this->createMock(Http::class); + $this->couponManagement = $this->createMock(GuestCouponManagementInterface::class); + $this->processor = new CheckoutDataProcessor( - $this->productRepository, - $this->createMock(GuestCouponManagementInterface::class), - $this->createMock(AgentProfileParser::class), - $this->createMock(Http::class), - $messageFactory + $this->lineItemWriter, + new AddressWriter($this->createMock(RegionResolver::class)), + new PersonalInformationCopier(), + $this->shippingMethodWriter, + $this->couponManagement, + $this->agentProfileParser, + $this->httpRequest, + $messageFactory, + new BuyerWriter() ); } @@ -244,16 +279,19 @@ public function testResolveBillingCountryKeepsACountryThatWasAlreadyResolved(): /** * @return void */ - public function testProcessLineItemsLoadsProductsInTheQuoteStoreScope(): void + public function testProcessLineItemsHandsTheSubmittedSkusToTheSharedWriter(): void { $quote = $this->createQuote(); - $this->productRepository->expects($this->once()) - ->method('get') - ->with('24-MB04', false, self::STORE_ID) - ->willReturn($this->createSalableProduct()); + $this->lineItemWriter->expects($this->once()) + ->method('write') + ->with($quote, [['sku' => '24-MB04', 'quantity' => 1], ['sku' => 'OTHER', 'quantity' => 4]]) + ->willReturn([]); - $this->processor->processLineItems($quote, [$this->createLineItem('24-MB04', 1)]); + $this->processor->processLineItems( + $quote, + [$this->createLineItem('24-MB04', 1), $this->createLineItem('OTHER', 4)] + ); } /** @@ -262,15 +300,13 @@ public function testProcessLineItemsLoadsProductsInTheQuoteStoreScope(): void public function testProcessLineItemsReportsAnUnknownSkuInsteadOfThrowing(): void { $quote = $this->createQuote(); - $quote->expects($this->never())->method('addProduct'); - - $this->productRepository->method('get')->willThrowException(new NoSuchEntityException()); + $this->writerReturns([new LineItemResult(0, 'NO-SUCH-SKU', LineItemOutcome::NotFound)]); $this->processor->processLineItems($quote, [$this->createLineItem('NO-SUCH-SKU', 1)]); $this->assertCount(1, $this->createdMessages); $this->assertSame('error', $this->createdMessages[0]['type']); - $this->assertSame(CheckoutDataProcessor::CODE_INVALID, $this->createdMessages[0]['code']); + $this->assertSame(CheckoutDataProcessor::CODE_NOT_FOUND, $this->createdMessages[0]['code']); $this->assertSame('$.line_items[0].item.id', $this->createdMessages[0]['path']); $this->assertSame(MessageInterface::SEVERITY_RECOVERABLE, $this->createdMessages[0]['severity']); $this->assertStringContainsString('NO-SUCH-SKU', $this->createdMessages[0]['content']); @@ -282,11 +318,7 @@ public function testProcessLineItemsReportsAnUnknownSkuInsteadOfThrowing(): void public function testProcessLineItemsReportsAnUnsalableProduct(): void { $quote = $this->createQuote(); - $quote->expects($this->never())->method('addProduct'); - - $product = $this->createMock(Product::class); - $product->method('isSalable')->willReturn(false); - $this->productRepository->method('get')->willReturn($product); + $this->writerReturns([new LineItemResult(0, 'OOS-SKU', LineItemOutcome::NotSalable)]); $this->processor->processLineItems($quote, [$this->createLineItem('OOS-SKU', 1)]); @@ -295,23 +327,71 @@ public function testProcessLineItemsReportsAnUnsalableProduct(): void } /** - * One bad SKU must not cost the caller the rest of the cart. + * A refusal carries the framework's own explanation rather than a generic message. + * + * @return void + */ + public function testProcessLineItemsReportsARefusalWithItsReason(): void + { + $quote = $this->createQuote(); + $this->writerReturns([ + new LineItemResult(0, '24-MB04', LineItemOutcome::Rejected, 'Requested qty is not available'), + ]); + + $this->processor->processLineItems($quote, [$this->createLineItem('24-MB04', 99)]); + + $this->assertCount(1, $this->createdMessages); + $this->assertSame(CheckoutDataProcessor::CODE_INVALID, $this->createdMessages[0]['code']); + $this->assertSame('Requested qty is not available', $this->createdMessages[0]['content']); + } + + /** + * @return void + */ + public function testProcessLineItemsReportsAQuantityTheStoreCannotSupplyAsOutOfStock(): void + { + $quote = $this->createQuote(); + $this->writerReturns([ + new LineItemResult(0, '24-MB04', LineItemOutcome::InsufficientStock, 'Only 3 left'), + ]); + + $this->processor->processLineItems($quote, [$this->createLineItem('24-MB04', 99)]); + + $this->assertCount(1, $this->createdMessages); + $this->assertSame(CheckoutDataProcessor::CODE_OUT_OF_STOCK, $this->createdMessages[0]['code']); + $this->assertSame('Only 3 left', $this->createdMessages[0]['content']); + } + + /** + * @return void + */ + public function testProcessLineItemsReportsAQuantityTheStoreWillNotSellInAsInvalid(): void + { + $quote = $this->createQuote(); + $this->writerReturns([ + new LineItemResult(0, '24-MB04', LineItemOutcome::InvalidQuantity, 'The fewest you may purchase is 5.'), + ]); + + $this->processor->processLineItems($quote, [$this->createLineItem('24-MB04', 1)]); + + $this->assertCount(1, $this->createdMessages); + $this->assertSame(CheckoutDataProcessor::CODE_INVALID, $this->createdMessages[0]['code']); + $this->assertSame('The fewest you may purchase is 5.', $this->createdMessages[0]['content']); + } + + /** + * One bad SKU must not cost the caller the rest of the cart: the added item raises no message, and + * the failed one is reported at its own index. * * @return void */ public function testProcessLineItemsKeepsAddingTheRemainingItems(): void { $quote = $this->createQuote(); - $quote->expects($this->once())->method('addProduct'); - - $this->productRepository->method('get')->willReturnCallback( - function (string $sku) { - if ($sku === 'NO-SUCH-SKU') { - throw new NoSuchEntityException(); - } - return $this->createSalableProduct(); - } - ); + $this->writerReturns([ + new LineItemResult(0, 'NO-SUCH-SKU', LineItemOutcome::NotFound), + new LineItemResult(1, '24-MB04', LineItemOutcome::Added), + ]); $this->processor->processLineItems( $quote, @@ -322,13 +402,48 @@ function (string $sku) { $this->assertSame('$.line_items[0].item.id', $this->createdMessages[0]['path']); } + /** + * The URL was parsed out of the agent's profile and then dropped, so nothing was ever sent order + * events. It is persisted with the checkout because the header is only on the agent's own requests. + * + * @return void + */ + public function testTheAgentsWebhookUrlIsReadFromItsProfile(): void + { + $this->httpRequest->method('getHeader')->willReturn('profile="https://agent.test/.well-known/ucp"'); + + $this->agentProfileParser->method('parseWebhookUrl')->willReturn('https://agent.test/hooks/orders'); + + $this->assertSame('https://agent.test/hooks/orders', $this->processor->getAgentWebhookUrl()); + } + + /** + * @return void + */ + public function testNoAgentHeaderMeansNoWebhookUrl(): void + { + $this->httpRequest->method('getHeader')->willReturn(false); + $this->agentProfileParser->expects($this->never())->method('parseWebhookUrl'); + + $this->assertNull($this->processor->getAgentWebhookUrl()); + } + + /** + * @param LineItemResult[] $results + * @return void + */ + private function writerReturns(array $results): void + { + $this->lineItemWriter->method('write')->willReturn($results); + } + /** * @return void */ public function testValidateHandsTheCollectedMessagesToTheResponseBuilder(): void { $quote = $this->createQuote(); - $this->productRepository->method('get')->willThrowException(new NoSuchEntityException()); + $this->writerReturns([new LineItemResult(0, 'NO-SUCH-SKU', LineItemOutcome::NotFound)]); $this->assertNull($this->processor->validate($quote)); @@ -381,11 +496,108 @@ private function createAddress(): Address 'getLastname', 'getTelephone', 'getEmail', + 'getStreet', + 'getStreetLine', + 'getCity', + 'getPostcode', + 'getRegionId', ]) ->addMethods(['setCollectShippingRates']) ->getMock(); } + /** + * A store bills where it ships unless the agent named a billing address of its own. + * + * @return void + */ + public function testTheBillingAddressFallsBackToTheShippingOne(): void + { + $shipping = $this->createAddress(); + $shipping->method('getStreet')->willReturn(['1 Main St']); + $shipping->method('getCity')->willReturn('Austin'); + $shipping->method('getCountryId')->willReturn('US'); + $shipping->method('getPostcode')->willReturn('78701'); + + $billing = $this->createAddress(); + $billing->method('getStreetLine')->willReturn(''); + $billing->expects($this->once())->method('setStreet')->with(['1 Main St']); + $billing->expects($this->once())->method('setCity')->with('Austin'); + $billing->expects($this->once())->method('setCountryId')->with('US'); + $billing->expects($this->once())->method('setPostcode')->with('78701'); + + $this->processor->fillBillingFromShipping($this->createQuote($shipping, $billing)); + } + + /** + * @return void + */ + public function testABillingAddressTheAgentGaveIsLeftAlone(): void + { + $billing = $this->createAddress(); + $billing->method('getStreetLine')->willReturn('9 Billing Road'); + $billing->expects($this->never())->method('setStreet'); + + $this->processor->fillBillingFromShipping($this->createQuote(null, $billing)); + } + + /** + * Magento carries one coupon per cart, so an agent that sent two has to be told which one counted. + * + * @return void + */ + public function testASecondDiscountCodeIsReportedRatherThanDropped(): void + { + $quote = $this->createQuote(); + $this->couponManagement->expects($this->once())->method('set')->with('cart-1', '10OFF'); + + $this->processor->processDiscountInformation('cart-1', $quote, $this->discounts(['10OFF', 'WELCOME20'])); + + $this->assertCount(1, $this->createdMessages); + $this->assertSame(CheckoutDataProcessor::CODE_NOT_APPLIED, $this->createdMessages[0]['code']); + $this->assertSame('$.discounts.codes[1]', $this->createdMessages[0]['path']); + $this->assertStringContainsString('WELCOME20', $this->createdMessages[0]['content']); + } + + /** + * @return void + */ + public function testASingleDiscountCodeIsAppliedWithNothingToReport(): void + { + $quote = $this->createQuote(); + $this->couponManagement->expects($this->once())->method('set')->with('cart-1', '10OFF'); + + $this->processor->processDiscountInformation('cart-1', $quote, $this->discounts(['10OFF'])); + + $this->assertSame([], $this->createdMessages); + } + + /** + * @return void + */ + public function testNoCodesAtAllClearsTheCoupon(): void + { + $quote = $this->createQuote(); + $this->couponManagement->expects($this->once())->method('remove')->with('cart-1'); + $this->couponManagement->expects($this->never())->method('set'); + + $this->processor->processDiscountInformation('cart-1', $quote, $this->discounts([])); + + $this->assertSame([], $this->createdMessages); + } + + /** + * @param string[] $codes + * @return DiscountResponseDiscountsObjectInterface&MockObject + */ + private function discounts(array $codes): DiscountResponseDiscountsObjectInterface + { + $discounts = $this->createMock(DiscountResponseDiscountsObjectInterface::class); + $discounts->method('getCodes')->willReturn($codes); + + return $discounts; + } + /** * @param Address|null $shippingAddress * @param Address|null $billingAddress @@ -395,7 +607,15 @@ private function createQuote(?Address $shippingAddress = null, ?Address $billing { $quote = $this->getMockBuilder(Quote::class) ->disableOriginalConstructor() - ->onlyMethods(['getShippingAddress', 'getBillingAddress', 'getStoreId', 'removeAllItems', 'addProduct']) + ->onlyMethods([ + 'getShippingAddress', + 'getBillingAddress', + 'getStoreId', + 'removeAllItems', + 'addProduct', + 'collectTotals', + 'getIsVirtual', + ]) ->getMock(); $quote->method('getShippingAddress')->willReturn($shippingAddress ?? $this->createAddress()); @@ -486,15 +706,4 @@ private function createLineItem(string $sku, int $quantity): LineItemCreateReque return $lineItem; } - - /** - * @return Product&MockObject - */ - private function createSalableProduct(): Product - { - $product = $this->createMock(Product::class); - $product->method('isSalable')->willReturn(true); - - return $product; - } } diff --git a/Test/Unit/Model/Service/Shopping/CheckoutGuardTest.php b/Test/Unit/Model/Service/Shopping/CheckoutGuardTest.php new file mode 100644 index 0000000..20d596a --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/CheckoutGuardTest.php @@ -0,0 +1,107 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping; + +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; +use Magebit\AgenticCore\Model\Checkout\StateResolver; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\Service\Shopping\CheckoutGuard; +use Magento\Quote\Api\Data\CartInterface; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class CheckoutGuardTest extends TestCase +{ + private const CHECKOUT_ID = 'masked-checkout-id'; + + private OrderLinkRepositoryInterface&MockObject $orderLinkRepository; + + private CheckoutGuard $guard; + + /** + * @return void + */ + protected function setUp(): void + { + $this->orderLinkRepository = $this->createMock(OrderLinkRepositoryInterface::class); + $this->guard = new CheckoutGuard(new StateResolver(), $this->orderLinkRepository); + } + + /** + * @return void + */ + public function testAnOpenCheckoutIsLetThrough(): void + { + $this->expectNotToPerformAssertions(); + $this->orderLinkRepository->method('findOrderId')->willReturn(null); + + $this->guard->assertOpen($this->quote(true), self::CHECKOUT_ID); + } + + /** + * @return void + */ + public function testACanceledCheckoutIsRefusedAsAConflict(): void + { + $this->orderLinkRepository->method('findOrderId')->willReturn(null); + + try { + $this->guard->assertOpen($this->quote(false), self::CHECKOUT_ID); + $this->fail('A canceled checkout must not accept changes.'); + } catch (UcpException $exception) { + $this->assertSame(409, $exception->getStatusCode()); + $this->assertSame(CheckoutGuard::CODE_CANCELED, $exception->getErrorCode()); + } + } + + /** + * Placing an order deactivates the quote, so a completed checkout would otherwise report itself + * canceled and send the agent looking for the wrong thing. + * + * @return void + */ + public function testACompletedCheckoutIsRefusedAsCompletedRatherThanCanceled(): void + { + $this->orderLinkRepository->method('findOrderId')->willReturn(42); + + try { + $this->guard->assertOpen($this->quote(false), self::CHECKOUT_ID); + $this->fail('A completed checkout must not accept changes.'); + } catch (UcpException $exception) { + $this->assertSame(409, $exception->getStatusCode()); + $this->assertSame(CheckoutGuard::CODE_COMPLETED, $exception->getErrorCode()); + } + } + + /** + * @return void + */ + public function testCompletionIsRecognisedFromTheOrderLink(): void + { + $this->orderLinkRepository->method('findOrderId')->willReturn(42); + + $this->assertTrue($this->guard->isCompleted($this->quote(false), self::CHECKOUT_ID)); + } + + /** + * @param bool $isActive + * @return CartInterface&MockObject + */ + private function quote(bool $isActive): CartInterface&MockObject + { + $quote = $this->createMock(CartInterface::class); + $quote->method('getIsActive')->willReturn($isActive); + + return $quote; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/CheckoutStatusTest.php b/Test/Unit/Model/Service/Shopping/Converter/CheckoutStatusTest.php index 66a14b3..7b62a8f 100644 --- a/Test/Unit/Model/Service/Shopping/Converter/CheckoutStatusTest.php +++ b/Test/Unit/Model/Service/Shopping/Converter/CheckoutStatusTest.php @@ -12,6 +12,7 @@ namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; +use Magebit\AgenticCore\Model\Checkout\StateResolver; use Magebit\UniversalCommerce\Api\Service\Shopping\CheckoutResponseInterface; use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToCheckoutResponse; @@ -26,14 +27,20 @@ class CheckoutStatusTest extends TestCase private QuoteToCheckoutResponse $converter; /** - * The status rule is pure; instantiate without the constructor so the test - * does not have to stand up eleven collaborators to exercise it. + * The status rule is pure; instantiate without the constructor so the test does not have to stand + * up eleven collaborators, then supply only the resolver it delegates to. What is asserted here is + * the mapping onto this protocol's vocabulary — the resolution itself is covered in the base module. * * @return void */ protected function setUp(): void { - $this->converter = (new ReflectionClass(QuoteToCheckoutResponse::class))->newInstanceWithoutConstructor(); + $reflection = new ReflectionClass(QuoteToCheckoutResponse::class); + $this->converter = $reflection->newInstanceWithoutConstructor(); + + $property = $reflection->getProperty('stateResolver'); + $property->setAccessible(true); + $property->setValue($this->converter, new StateResolver()); } /** diff --git a/Test/Unit/Model/Service/Shopping/Converter/OrderItemToOrderLineItemTest.php b/Test/Unit/Model/Service/Shopping/Converter/OrderItemToOrderLineItemTest.php new file mode 100644 index 0000000..986aa8a --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/OrderItemToOrderLineItemTest.php @@ -0,0 +1,432 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\ItemResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemInterface; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemQuantityInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\Types\ItemResponse; +use Magebit\UcpSpec\Data\Shopping\Types\OrderLineItem; +use Magebit\UcpSpec\Data\Shopping\Types\OrderLineItemQuantity; +use Magebit\UcpSpec\Data\Shopping\Types\TotalResponse; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderItemToOrderLineItem; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\ResourceModel\Product\Collection as ProductCollection; +use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; +use Magento\Sales\Api\Data\OrderItemInterface; +use PHPUnit\Framework\TestCase; + +class OrderItemToOrderLineItemTest extends TestCase +{ + /** @var OrderItemToOrderLineItem */ + private OrderItemToOrderLineItem $converter; + + /** + * @return void + */ + protected function setUp(): void + { + $lineItemFactory = $this->createMock(OrderLineItemInterfaceFactory::class); + $lineItemFactory->method('create')->willReturnCallback(fn (): OrderLineItem => new OrderLineItem()); + + $quantityFactory = $this->createMock(OrderLineItemQuantityInterfaceFactory::class); + $quantityFactory->method('create') + ->willReturnCallback(fn (): OrderLineItemQuantity => new OrderLineItemQuantity()); + + $itemFactory = $this->createMock(ItemResponseInterfaceFactory::class); + $itemFactory->method('create')->willReturnCallback(fn (): ItemResponse => new ItemResponse()); + + $totalFactory = $this->createMock(TotalResponseInterfaceFactory::class); + $totalFactory->method('create')->willReturnCallback(fn (): TotalResponse => new TotalResponse()); + + $this->converter = new OrderItemToOrderLineItem( + $lineItemFactory, + $quantityFactory, + $itemFactory, + $totalFactory, + $this->createMock(CollectionFactory::class), + $this->createMock(ImageHelper::class), + new MinorUnits() + ); + } + + /** + * A twenty line order used to load twenty products; the pictures now come from one load, and the + * same product on two lines is asked for once. + * + * @return void + */ + public function testPicturesForAWholeOrderComeFromOneProductLoad(): void + { + $filtered = []; + $collection = $this->createMock(ProductCollection::class); + $collection->method('addIdFilter')->willReturnCallback( + function (array $ids) use ($collection, &$filtered): ProductCollection { + $filtered = $ids; + + return $collection; + } + ); + $collection->method('getItems')->willReturn([$this->product(10), $this->product(20)]); + + $collectionFactory = $this->createMock(CollectionFactory::class); + $collectionFactory->expects($this->once())->method('create')->willReturn($collection); + + $urls = $this->converterWith($collectionFactory)->imageUrls([ + $this->orderItem(['qty_ordered' => 1.0], productId: 10), + $this->orderItem(['qty_ordered' => 1.0], productId: 20), + $this->orderItem(['qty_ordered' => 1.0], productId: 10), + ]); + + $this->assertSame([10, 20], $filtered); + $this->assertSame( + [10 => 'https://example.com/10.jpg', 20 => 'https://example.com/20.jpg'], + $urls + ); + } + + /** + * @return void + */ + public function testNoProductsAreLoadedForAnOrderWithoutProductIds(): void + { + $collectionFactory = $this->createMock(CollectionFactory::class); + $collectionFactory->expects($this->never())->method('create'); + + $this->assertSame([], $this->converterWith($collectionFactory)->imageUrls([ + $this->orderItem(['qty_ordered' => 1.0]), + ])); + } + + /** + * A product deleted since the order was placed is missing from the load, and the line still has to + * convert — just without a picture. + * + * @return void + */ + public function testALineWhoseProductIsGoneStillConverts(): void + { + $collection = $this->createMock(ProductCollection::class); + $collection->method('addIdFilter')->willReturn($collection); + $collection->method('getItems')->willReturn([]); + + $collectionFactory = $this->createMock(CollectionFactory::class); + $collectionFactory->method('create')->willReturn($collection); + + $converter = $this->converterWith($collectionFactory); + $orderItem = $this->orderItem(['qty_ordered' => 1.0], productId: 99); + $urls = $converter->imageUrls([$orderItem]); + + $lineItem = $converter->convert($orderItem, 'USD', 'line-1', null, $urls[99] ?? null); + + $this->assertSame([], $urls); + $this->assertSame('SKU-1', $lineItem->getItem()->getId()); + $this->assertNull($lineItem->getItem()->getImageUrl()); + } + + /** + * @return void + */ + public function testTheResolvedPictureLandsOnTheItem(): void + { + $lineItem = $this->converter->convert( + $this->orderItem(['qty_ordered' => 1.0], productId: 10), + 'USD', + 'line-1', + null, + 'https://example.com/10.jpg' + ); + + $this->assertSame('https://example.com/10.jpg', $lineItem->getItem()->getImageUrl()); + } + + /** + * @param CollectionFactory $collectionFactory + * @return OrderItemToOrderLineItem + */ + private function converterWith(CollectionFactory $collectionFactory): OrderItemToOrderLineItem + { + $lineItemFactory = $this->createMock(OrderLineItemInterfaceFactory::class); + $lineItemFactory->method('create')->willReturnCallback(fn (): OrderLineItem => new OrderLineItem()); + + $quantityFactory = $this->createMock(OrderLineItemQuantityInterfaceFactory::class); + $quantityFactory->method('create') + ->willReturnCallback(fn (): OrderLineItemQuantity => new OrderLineItemQuantity()); + + $itemFactory = $this->createMock(ItemResponseInterfaceFactory::class); + $itemFactory->method('create')->willReturnCallback(fn (): ItemResponse => new ItemResponse()); + + $totalFactory = $this->createMock(TotalResponseInterfaceFactory::class); + $totalFactory->method('create')->willReturnCallback(fn (): TotalResponse => new TotalResponse()); + + return new OrderItemToOrderLineItem( + $lineItemFactory, + $quantityFactory, + $itemFactory, + $totalFactory, + $collectionFactory, + $this->imageHelper(), + new MinorUnits() + ); + } + + /** + * Stands in for the image helper's fluent call, returning a URL built from the product's id. + * + * @return ImageHelper + */ + private function imageHelper(): ImageHelper + { + $imageHelper = $this->createMock(ImageHelper::class); + $imageHelper->method('init')->willReturnCallback( + function (Product $product): ImageHelper { + $initialised = $this->createMock(ImageHelper::class); + $initialised->method('getUrl')->willReturn('https://example.com/' . $product->getId() . '.jpg'); + + return $initialised; + } + ); + + return $imageHelper; + } + + /** + * @param int $productId + * @return Product + */ + private function product(int $productId): Product + { + $product = $this->createMock(Product::class); + $product->method('getId')->willReturn($productId); + + return $product; + } + + /** + * @return array, 1: string}> + */ + public static function statusProvider(): array + { + return [ + 'nothing shipped yet' => [['qty_ordered' => 2.0], OrderLineItemInterface::STATUS_PROCESSING], + 'some shipped' => [ + ['qty_ordered' => 3.0, 'qty_shipped' => 1.0], + OrderLineItemInterface::STATUS_PARTIAL, + ], + 'all shipped' => [ + ['qty_ordered' => 2.0, 'qty_shipped' => 2.0], + OrderLineItemInterface::STATUS_FULFILLED, + ], + 'wholly canceled' => [ + ['qty_ordered' => 2.0, 'qty_canceled' => 2.0], + OrderLineItemInterface::STATUS_REMOVED, + ], + 'wholly refunded' => [ + ['qty_ordered' => 1.0, 'qty_shipped' => 1.0, 'qty_refunded' => 1.0], + OrderLineItemInterface::STATUS_REMOVED, + ], + 'shipped then partly returned' => [ + ['qty_ordered' => 3.0, 'qty_shipped' => 3.0, 'qty_refunded' => 1.0], + OrderLineItemInterface::STATUS_FULFILLED, + ], + ]; + } + + /** + * The schema dictates the derivation, so each of its four cases is pinned here rather than left to + * whatever the quantities happen to produce. + * + * @dataProvider statusProvider + * @param array $quantities + * @param string $expected + * @return void + */ + public function testStatusFollowsTheQuantities(array $quantities, string $expected): void + { + $this->assertSame($expected, $this->convert($quantities)->getStatus()); + } + + /** + * A canceled quantity is gone but was still sold, so the two numbers have to differ. + * + * @return void + */ + public function testOriginalKeepsWhatTheCheckoutSoldAfterACancellation(): void + { + $quantity = $this->convert(['qty_ordered' => 5.0, 'qty_canceled' => 2.0])->getQuantity(); + + $this->assertSame(5, $quantity->getOriginal()); + $this->assertSame(3, $quantity->getTotal()); + } + + /** + * Refunding more than was ordered would otherwise report a negative quantity, which the schema + * forbids. + * + * @return void + */ + public function testTotalNeverGoesNegative(): void + { + $quantity = $this->convert(['qty_ordered' => 1.0, 'qty_refunded' => 2.0])->getQuantity(); + + $this->assertSame(0, $quantity->getTotal()); + } + + /** + * Magento keeps the shipped quantity after a return, so the raw figures say three shipped out of one + * still active — a quantity both gone and in hand. + * + * @return void + */ + public function testFulfilledNeverExceedsWhatIsStillActive(): void + { + $quantity = $this->convert([ + 'qty_ordered' => 3.0, + 'qty_shipped' => 3.0, + 'qty_refunded' => 2.0, + ])->getQuantity(); + + $this->assertSame(1, $quantity->getTotal()); + $this->assertSame(1, $quantity->getFulfilled()); + } + + /** + * Nothing ships for a virtual item, so shipped quantity would leave it forever unfulfilled. + * + * @return void + */ + public function testAVirtualItemIsFulfilledByInvoicing(): void + { + $lineItem = $this->convert(['qty_ordered' => 1.0, 'qty_invoiced' => 1.0], isVirtual: true); + + $this->assertSame(1, $lineItem->getQuantity()->getFulfilled()); + $this->assertSame(OrderLineItemInterface::STATUS_FULFILLED, $lineItem->getStatus()); + } + + /** + * @return void + */ + public function testAShippableItemIsNotFulfilledByInvoicingAlone(): void + { + $lineItem = $this->convert(['qty_ordered' => 1.0, 'qty_invoiced' => 1.0]); + + $this->assertSame(0, $lineItem->getQuantity()->getFulfilled()); + $this->assertSame(OrderLineItemInterface::STATUS_PROCESSING, $lineItem->getStatus()); + } + + /** + * Magento's own formula, from the tax module's item price renderer: + * row_total - discount + tax + discount_tax_compensation. + * + * @return void + */ + public function testTheLineTotalAccountsForTheDiscountAndTheTax(): void + { + $totals = $this->totalsOf($this->convert([ + 'qty_ordered' => 2.0, + 'row_total' => 40.00, + 'discount_amount' => 5.00, + 'tax_amount' => 2.80, + 'discount_tax_compensation_amount' => 0.40, + ])); + + $this->assertSame(4000, $totals[TotalTypeInterface::TYPE_SUBTOTAL]); + $this->assertSame(-500, $totals[TotalTypeInterface::TYPE_ITEMS_DISCOUNT]); + $this->assertSame(280, $totals[TotalTypeInterface::TYPE_TAX]); + $this->assertSame(3820, $totals[TotalTypeInterface::TYPE_TOTAL]); + } + + /** + * @return void + */ + public function testAChildItemPointsAtItsParent(): void + { + $lineItem = $this->converter->convert( + $this->orderItem(['qty_ordered' => 1.0]), + 'USD', + 'child-1', + 'parent-1' + ); + + $this->assertSame('parent-1', $lineItem->getParentId()); + } + + /** + * @return void + */ + public function testATopLevelItemHasNoParent(): void + { + $this->assertNull($this->convert(['qty_ordered' => 1.0])->getParentId()); + } + + /** + * @param OrderLineItemInterface $lineItem + * @return array + */ + private function totalsOf(OrderLineItemInterface $lineItem): array + { + $result = []; + + foreach ($lineItem->getTotals() as $total) { + /** @var TotalResponseInterface $total */ + $result[$total->getType()] = $total->getAmount(); + } + + return $result; + } + + /** + * @param array $data + * @param bool $isVirtual + * @return OrderLineItemInterface + */ + private function convert(array $data, bool $isVirtual = false): OrderLineItemInterface + { + return $this->converter->convert($this->orderItem($data, $isVirtual), 'USD', 'line-1'); + } + + /** + * @param array $data + * @param bool $isVirtual + * @param int|null $productId + * @return OrderItemInterface + */ + private function orderItem(array $data, bool $isVirtual = false, ?int $productId = null): OrderItemInterface + { + $item = $this->createMock(OrderItemInterface::class); + $item->method('getSku')->willReturn('SKU-1'); + $item->method('getName')->willReturn('Product One'); + $item->method('getPrice')->willReturn(20.00); + $item->method('getProductId')->willReturn($productId); + $item->method('getStoreId')->willReturn(1); + $item->method('getIsVirtual')->willReturn($isVirtual ? 1 : 0); + $item->method('getQtyOrdered')->willReturn($data['qty_ordered'] ?? 0.0); + $item->method('getQtyShipped')->willReturn($data['qty_shipped'] ?? 0.0); + $item->method('getQtyInvoiced')->willReturn($data['qty_invoiced'] ?? 0.0); + $item->method('getQtyCanceled')->willReturn($data['qty_canceled'] ?? 0.0); + $item->method('getQtyRefunded')->willReturn($data['qty_refunded'] ?? 0.0); + $item->method('getRowTotal')->willReturn($data['row_total'] ?? 0.0); + $item->method('getDiscountAmount')->willReturn($data['discount_amount'] ?? 0.0); + $item->method('getTaxAmount')->willReturn($data['tax_amount'] ?? 0.0); + $item->method('getDiscountTaxCompensationAmount') + ->willReturn($data['discount_tax_compensation_amount'] ?? 0.0); + + return $item; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/OrderToAdjustmentsTest.php b/Test/Unit/Model/Service/Shopping/Converter/OrderToAdjustmentsTest.php new file mode 100644 index 0000000..86fb905 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/OrderToAdjustmentsTest.php @@ -0,0 +1,293 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentInterface; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentLineItemsItemInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\Types\Adjustment; +use Magebit\UcpSpec\Data\Shopping\Types\AdjustmentLineItemsItem; +use Magebit\UcpSpec\Data\Shopping\Types\TotalResponse; +use Magebit\UniversalCommerce\Api\Data\OrderAdjustmentInterface; +use Magebit\UniversalCommerce\Api\OrderAdjustmentRepositoryInterface; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToAdjustments; +use Magebit\UniversalCommerce\Model\Timestamp; +use Magento\Framework\Api\SearchCriteria; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Sales\Api\CreditmemoRepositoryInterface; +use Magento\Sales\Api\Data\CreditmemoSearchResultInterface; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Creditmemo; +use PHPUnit\Framework\TestCase; + +class OrderToAdjustmentsTest extends TestCase +{ + private const LINE_ITEM_IDS = [11 => 'line-a', 12 => 'line-b']; + + /** + * @param Creditmemo[] $creditmemos + * @param array> $stored Adjustments an agent submitted earlier + * @return OrderToAdjustments + */ + private function converterFor(array $creditmemos, array $stored = []): OrderToAdjustments + { + $adjustmentFactory = $this->createMock(AdjustmentInterfaceFactory::class); + $adjustmentFactory->method('create')->willReturnCallback( + static fn (array $arguments = []): Adjustment => new Adjustment($arguments['data'] ?? []) + ); + + $lineItemFactory = $this->createMock(AdjustmentLineItemsItemInterfaceFactory::class); + $lineItemFactory->method('create') + ->willReturnCallback(fn (): AdjustmentLineItemsItem => new AdjustmentLineItemsItem()); + + $totalFactory = $this->createMock(TotalResponseInterfaceFactory::class); + $totalFactory->method('create')->willReturnCallback(fn (): TotalResponse => new TotalResponse()); + + $searchResult = $this->createMock(CreditmemoSearchResultInterface::class); + $searchResult->method('getItems')->willReturn($creditmemos); + + $repository = $this->createMock(CreditmemoRepositoryInterface::class); + $repository->method('getList')->willReturn($searchResult); + + $builder = $this->createMock(SearchCriteriaBuilder::class); + $builder->method('addFilter')->willReturnSelf(); + $builder->method('create')->willReturn($this->createMock(SearchCriteria::class)); + + $rows = []; + + foreach ($stored as $payload) { + $row = $this->createMock(OrderAdjustmentInterface::class); + $row->method('getAdjustmentId')->willReturn((string) ($payload['id'] ?? '')); + $row->method('getPayload')->willReturn((string) json_encode($payload)); + $rows[] = $row; + } + + $orderAdjustments = $this->createMock(OrderAdjustmentRepositoryInterface::class); + $orderAdjustments->method('getByOrderId')->willReturn($rows); + + return new OrderToAdjustments( + $adjustmentFactory, + $lineItemFactory, + $totalFactory, + new MinorUnits(), + new Timestamp(), + $repository, + $builder, + $orderAdjustments + ); + } + + /** + * @return void + */ + public function testAnUntouchedOrderHasNoAdjustments(): void + { + $this->assertSame([], $this->convert()); + } + + /** + * @return void + */ + public function testACreditMemoBecomesARefund(): void + { + $adjustments = $this->convert(creditmemos: [$this->creditmemo('CM-1', 25.00, [11 => 1.0])]); + + $this->assertCount(1, $adjustments); + $this->assertSame('CM-1', $adjustments[0]->getId()); + $this->assertSame(OrderToAdjustments::TYPE_REFUND, $adjustments[0]->getType()); + $this->assertSame(AdjustmentInterface::STATUS_COMPLETED, $adjustments[0]->getStatus()); + $this->assertSame('2026-03-04T09:30:00+00:00', $adjustments[0]->getOccurredAt()); + } + + /** + * The sign is direction: this money went back to the buyer. + * + * @return void + */ + public function testARefundedAmountIsNegative(): void + { + $adjustments = $this->convert(creditmemos: [$this->creditmemo('CM-1', 25.00, [11 => 1.0])]); + + $this->assertSame(-2500, $adjustments[0]->getTotals()[0]->getAmount()); + } + + /** + * The schema reads a negative quantity as a reduction, which is what a return is. + * + * @return void + */ + public function testARefundedQuantityIsNegative(): void + { + $adjustments = $this->convert(creditmemos: [$this->creditmemo('CM-1', 25.00, [11 => 2.0])]); + + $this->assertSame('line-a', $adjustments[0]->getLineItems()[0]->getId()); + $this->assertSame(-2, $adjustments[0]->getLineItems()[0]->getQuantity()); + } + + /** + * An adjustment can exist without touching line items — an adjustment-fee-only refund, say — so an + * unmatched credit memo item must not invent one. + * + * @return void + */ + public function testACreditMemoForNoKnownItemStillReportsTheMoney(): void + { + $adjustments = $this->convert(creditmemos: [$this->creditmemo('CM-2', 5.00, [99 => 1.0])]); + + $this->assertCount(1, $adjustments); + $this->assertNull($adjustments[0]->getLineItems()); + $this->assertSame(-500, $adjustments[0]->getTotals()[0]->getAmount()); + } + + /** + * @return void + */ + public function testACanceledOrderReportsACancellation(): void + { + $adjustments = $this->convert(isCanceled: true); + + $this->assertCount(1, $adjustments); + $this->assertSame(OrderToAdjustments::TYPE_CANCELLATION, $adjustments[0]->getType()); + $this->assertSame('cancellation-000000123', $adjustments[0]->getId()); + } + + /** + * @return void + */ + public function testRefundsAndACancellationAreBothReported(): void + { + $adjustments = $this->convert( + creditmemos: [$this->creditmemo('CM-1', 25.00, [11 => 1.0])], + isCanceled: true + ); + + $this->assertSame( + [OrderToAdjustments::TYPE_REFUND, OrderToAdjustments::TYPE_CANCELLATION], + array_map(fn (AdjustmentInterface $a): string => $a->getType(), $adjustments) + ); + } + + /** + * @return void + */ + public function testAnAdjustmentAnAgentSubmittedIsReportedBackUnchanged(): void + { + $adjustments = $this->convert(stored: [[ + 'id' => 'adj_1', + 'type' => 'refund', + 'occurred_at' => '2026-08-31T12:00:00Z', + 'status' => 'pending', + 'description' => 'Customer refund request', + ]]); + + $this->assertCount(1, $adjustments); + $this->assertSame('adj_1', $adjustments[0]->getId()); + $this->assertSame('pending', $adjustments[0]->getStatus()); + $this->assertSame('Customer refund request', $adjustments[0]->getDescription()); + } + + /** + * A request for a refund is not the refund, so when both name the same identifier the store's own + * record is the one reported. + * + * @return void + */ + public function testTheStoresOwnRecordOutranksAMatchingSubmission(): void + { + $adjustments = $this->convert( + creditmemos: [$this->creditmemo('CM-1', 25.00, [11 => 1.0])], + stored: [[ + 'id' => 'CM-1', + 'type' => 'refund', + 'occurred_at' => '2026-08-31T12:00:00Z', + 'status' => 'pending', + ]] + ); + + $this->assertCount(1, $adjustments); + $this->assertSame('completed', $adjustments[0]->getStatus()); + } + + /** + * @param Creditmemo[] $creditmemos + * @param bool $isCanceled + * @param array> $stored + * @return AdjustmentInterface[] + */ + private function convert(array $creditmemos = [], bool $isCanceled = false, array $stored = []): array + { + return $this->converterFor($creditmemos, $stored) + ->convert($this->order($isCanceled), self::LINE_ITEM_IDS); + } + + /** + * @param bool $isCanceled + * @return Order + */ + private function order(bool $isCanceled): Order + { + $order = $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'getEntityId', + 'isCanceled', + 'getOrderCurrencyCode', + 'getIncrementId', + 'getUpdatedAt', + ]) + ->getMock(); + $order->method('getEntityId')->willReturn(7); + $order->method('isCanceled')->willReturn($isCanceled); + $order->method('getOrderCurrencyCode')->willReturn('USD'); + $order->method('getIncrementId')->willReturn('000000123'); + $order->method('getUpdatedAt')->willReturn('2026-03-05 12:00:00'); + + return $order; + } + + /** + * @param string $incrementId + * @param float $grandTotal + * @param array $quantities Order item id to refunded quantity + * @return Creditmemo + */ + private function creditmemo(string $incrementId, float $grandTotal, array $quantities): Creditmemo + { + $items = []; + + foreach ($quantities as $orderItemId => $qty) { + $item = $this->getMockBuilder(Creditmemo\Item::class) + ->disableOriginalConstructor() + ->onlyMethods(['getOrderItemId', 'getQty']) + ->getMock(); + $item->method('getOrderItemId')->willReturn($orderItemId); + $item->method('getQty')->willReturn($qty); + + $items[] = $item; + } + + $creditmemo = $this->getMockBuilder(Creditmemo::class) + ->disableOriginalConstructor() + ->onlyMethods(['getIncrementId', 'getEntityId', 'getCreatedAt', 'getGrandTotal', 'getItems']) + ->getMock(); + $creditmemo->method('getIncrementId')->willReturn($incrementId); + $creditmemo->method('getEntityId')->willReturn(7); + $creditmemo->method('getCreatedAt')->willReturn('2026-03-04 09:30:00'); + $creditmemo->method('getGrandTotal')->willReturn($grandTotal); + $creditmemo->method('getItems')->willReturn($items); + + return $creditmemo; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/OrderToFulfillmentTest.php b/Test/Unit/Model/Service/Shopping/Converter/OrderToFulfillmentTest.php new file mode 100644 index 0000000..010c7d8 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/OrderToFulfillmentTest.php @@ -0,0 +1,344 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\UcpSpec\Api\Shopping\OrderResponseFulfillmentInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\ExpectationInterface; +use Magebit\UcpSpec\Api\Shopping\Types\ExpectationInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\ExpectationLineItemsItemInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentEventInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentEventLineItemsItemInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\PostalAddressInterface; +use Magebit\UcpSpec\Api\Shopping\Types\PostalAddressInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\OrderResponseFulfillment; +use Magebit\UcpSpec\Data\Shopping\Types\Expectation; +use Magebit\UcpSpec\Data\Shopping\Types\ExpectationLineItemsItem; +use Magebit\UcpSpec\Data\Shopping\Types\FulfillmentEvent; +use Magebit\UcpSpec\Data\Shopping\Types\FulfillmentEventLineItemsItem; +use Magebit\UcpSpec\Data\Shopping\Types\PostalAddress; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderAddressToPostalAddress; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToFulfillment; +use Magebit\UniversalCommerce\Model\Timestamp; +use Magento\Sales\Model\Order; +use Magento\Sales\Model\Order\Address as OrderAddress; +use Magento\Sales\Model\Order\Shipment; +use Magento\Sales\Model\Order\Shipment\Track; +use Magento\Framework\Api\SearchCriteria; +use Magento\Framework\Api\SearchCriteriaBuilder; +use Magento\Sales\Api\Data\ShipmentSearchResultInterface; +use Magento\Sales\Api\ShipmentRepositoryInterface; +use Magento\Shipping\Helper\Data as ShippingHelper; +use PHPUnit\Framework\TestCase; + +class OrderToFulfillmentTest extends TestCase +{ + private const LINE_ITEM_IDS = [11 => 'line-a', 12 => 'line-b']; + private const ORDERED = [11 => 2, 12 => 1]; + + /** + * @param Shipment[] $shipments + * @return OrderToFulfillment + */ + private function converterFor(array $shipments): OrderToFulfillment + { + $fulfillmentFactory = $this->createMock(OrderResponseFulfillmentInterfaceFactory::class); + $fulfillmentFactory->method('create') + ->willReturnCallback(fn (): OrderResponseFulfillment => new OrderResponseFulfillment()); + + $expectationFactory = $this->createMock(ExpectationInterfaceFactory::class); + $expectationFactory->method('create')->willReturnCallback(fn (): Expectation => new Expectation()); + + $expectationLineItemFactory = $this->createMock(ExpectationLineItemsItemInterfaceFactory::class); + $expectationLineItemFactory->method('create') + ->willReturnCallback(fn (): ExpectationLineItemsItem => new ExpectationLineItemsItem()); + + $eventFactory = $this->createMock(FulfillmentEventInterfaceFactory::class); + $eventFactory->method('create')->willReturnCallback(fn (): FulfillmentEvent => new FulfillmentEvent()); + + $eventLineItemFactory = $this->createMock(FulfillmentEventLineItemsItemInterfaceFactory::class); + $eventLineItemFactory->method('create') + ->willReturnCallback(fn (): FulfillmentEventLineItemsItem => new FulfillmentEventLineItemsItem()); + + $postalAddressFactory = $this->createMock(PostalAddressInterfaceFactory::class); + $postalAddressFactory->method('create')->willReturnCallback(fn (): PostalAddress => new PostalAddress()); + + $shippingHelper = $this->createMock(ShippingHelper::class); + $shippingHelper->method('getTrackingPopupUrlBySalesModel') + ->willReturn('https://merchant.test/shipping/tracking/popup?hash=abc'); + + $searchResult = $this->createMock(ShipmentSearchResultInterface::class); + $searchResult->method('getItems')->willReturn($shipments); + + $repository = $this->createMock(ShipmentRepositoryInterface::class); + $repository->method('getList')->willReturn($searchResult); + + $builder = $this->createMock(SearchCriteriaBuilder::class); + $builder->method('addFilter')->willReturnSelf(); + $builder->method('create')->willReturn($this->createMock(SearchCriteria::class)); + + return new OrderToFulfillment( + $fulfillmentFactory, + $expectationFactory, + $expectationLineItemFactory, + $eventFactory, + $eventLineItemFactory, + new OrderAddressToPostalAddress($postalAddressFactory), + $shippingHelper, + new Timestamp(), + $repository, + $builder + ); + } + + /** + * The expectation is what the buyer was told at checkout, so it covers everything bought regardless + * of what has shipped. + * + * @return void + */ + public function testTheExpectationCoversEveryOrderedLineItem(): void + { + $expectation = $this->convert()->getExpectations()[0]; + + $this->assertSame(OrderToFulfillment::EXPECTATION_ID, $expectation->getId()); + $this->assertSame(['line-a', 'line-b'], array_map( + fn ($lineItem): string => $lineItem->getId(), + $expectation->getLineItems() + )); + $this->assertSame([2, 1], array_map( + fn ($lineItem): int => $lineItem->getQuantity(), + $expectation->getLineItems() + )); + } + + /** + * @return void + */ + public function testTheDestinationIsTheShippingAddress(): void + { + $destination = $this->convert()->getExpectations()[0]->getDestination(); + + $this->assertInstanceOf(PostalAddressInterface::class, $destination); + $this->assertSame('Rigas iela 1', $destination->getStreetAddress()); + $this->assertSame('Riga', $destination->getAddressLocality()); + $this->assertSame('LV', $destination->getAddressCountry()); + } + + /** + * @return void + */ + public function testAShippedOrderIsAShippingExpectation(): void + { + $this->assertSame( + ExpectationInterface::METHOD_TYPE_SHIPPING, + $this->convert()->getExpectations()[0]->getMethodType() + ); + } + + /** + * @return void + */ + public function testAVirtualOrderIsADigitalExpectationAndFallsBackToBilling(): void + { + $expectation = $this->convert(isVirtual: true)->getExpectations()[0]; + + $this->assertSame(ExpectationInterface::METHOD_TYPE_DIGITAL, $expectation->getMethodType()); + $this->assertSame('Brivibas iela 2', $expectation->getDestination()->getStreetAddress()); + } + + /** + * @return void + */ + public function testCollectingInStoreIsAPickupExpectation(): void + { + $expectation = $this->convert(shippingMethod: 'instore_pickup')->getExpectations()[0]; + + $this->assertSame(ExpectationInterface::METHOD_TYPE_PICKUP, $expectation->getMethodType()); + } + + /** + * An order nothing has shipped for reports the expectation and an empty log, not a missing one. + * + * @return void + */ + public function testAnUnshippedOrderHasNoEvents(): void + { + $this->assertSame([], $this->convert()->getEvents()); + } + + /** + * @return void + */ + public function testAShipmentBecomesOneEvent(): void + { + $events = $this->convert(shipments: [$this->shipment('SHIP-1', [11 => 2.0], 'TRACK-9')])->getEvents(); + + $this->assertCount(1, $events); + $this->assertSame('SHIP-1', $events[0]->getId()); + $this->assertSame(OrderToFulfillment::EVENT_TYPE_SHIPPED, $events[0]->getType()); + $this->assertSame('2026-02-01T10:00:00+00:00', $events[0]->getOccurredAt()); + $this->assertSame('line-a', $events[0]->getLineItems()[0]->getId()); + $this->assertSame(2, $events[0]->getLineItems()[0]->getQuantity()); + } + + /** + * @return void + */ + public function testTrackingIsReportedWhenTheShipmentCarriesIt(): void + { + $events = $this->convert(shipments: [$this->shipment('SHIP-1', [11 => 1.0], 'TRACK-9')])->getEvents(); + + $this->assertSame('TRACK-9', $events[0]->getTrackingNumber()); + $this->assertSame('Federal Express', $events[0]->getCarrier()); + $this->assertSame('https://merchant.test/shipping/tracking/popup?hash=abc', $events[0]->getTrackingUrl()); + } + + /** + * Magento does not require a track, and the shipment still happened. + * + * @return void + */ + public function testAnUntrackedShipmentIsStillReportedAsShipped(): void + { + $events = $this->convert(shipments: [$this->shipment('SHIP-2', [11 => 1.0], null)])->getEvents(); + + $this->assertCount(1, $events); + $this->assertSame(OrderToFulfillment::EVENT_TYPE_SHIPPED, $events[0]->getType()); + $this->assertNull($events[0]->getTrackingNumber()); + } + + /** + * @param bool $isVirtual + * @param string $shippingMethod + * @param Shipment[] $shipments + * @return \Magebit\UcpSpec\Api\Shopping\OrderResponseFulfillmentInterface + */ + private function convert( + bool $isVirtual = false, + string $shippingMethod = 'flatrate_flatrate', + array $shipments = [] + ): mixed { + return $this->converterFor($shipments)->convert( + $this->order($isVirtual, $shippingMethod), + self::LINE_ITEM_IDS, + self::ORDERED + ); + } + + /** + * @param bool $isVirtual + * @param string $shippingMethod + * @return Order + */ + private function order(bool $isVirtual, string $shippingMethod): Order + { + $order = $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'getEntityId', + 'getIsVirtual', + 'getShippingMethod', + 'getShippingAddress', + 'getBillingAddress', + 'getShippingDescription', + ]) + ->getMock(); + $order->method('getEntityId')->willReturn(9); + $order->method('getIsVirtual')->willReturn($isVirtual); + $order->method('getShippingMethod')->willReturn($shippingMethod); + $order->method('getShippingAddress')->willReturn($isVirtual ? null : $this->address('Rigas iela 1')); + $order->method('getBillingAddress')->willReturn($this->address('Brivibas iela 2')); + $order->method('getShippingDescription')->willReturn('Flat Rate - Fixed'); + + return $order; + } + + /** + * @param string $street + * @return OrderAddress + */ + private function address(string $street): OrderAddress + { + $address = $this->getMockBuilder(OrderAddress::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'getStreet', + 'getCity', + 'getRegion', + 'getCountryId', + 'getPostcode', + 'getFirstname', + 'getLastname', + 'getTelephone', + ]) + ->getMock(); + $address->method('getStreet')->willReturn([$street]); + $address->method('getCity')->willReturn('Riga'); + $address->method('getRegion')->willReturn(null); + $address->method('getCountryId')->willReturn('LV'); + $address->method('getPostcode')->willReturn('LV-1010'); + $address->method('getFirstname')->willReturn('Anna'); + $address->method('getLastname')->willReturn('Berzina'); + $address->method('getTelephone')->willReturn('+37120000000'); + + return $address; + } + + /** + * @param string $incrementId + * @param array $quantities Order item id to shipped quantity + * @param string|null $trackNumber + * @return Shipment + */ + private function shipment(string $incrementId, array $quantities, ?string $trackNumber): Shipment + { + $items = []; + + foreach ($quantities as $orderItemId => $qty) { + $item = $this->getMockBuilder(Shipment\Item::class) + ->disableOriginalConstructor() + ->onlyMethods(['getOrderItemId', 'getQty']) + ->getMock(); + $item->method('getOrderItemId')->willReturn($orderItemId); + $item->method('getQty')->willReturn($qty); + + $items[] = $item; + } + + $tracks = []; + + if ($trackNumber !== null) { + $track = $this->getMockBuilder(Track::class) + ->disableOriginalConstructor() + ->onlyMethods(['getTrackNumber', 'getTitle', 'getCarrierCode']) + ->getMock(); + $track->method('getTrackNumber')->willReturn($trackNumber); + $track->method('getTitle')->willReturn('Federal Express'); + $track->method('getCarrierCode')->willReturn('fedex'); + + $tracks[] = $track; + } + + $shipment = $this->getMockBuilder(Shipment::class) + ->disableOriginalConstructor() + ->onlyMethods(['getIncrementId', 'getEntityId', 'getCreatedAt', 'getItems', 'getAllTracks']) + ->getMock(); + $shipment->method('getIncrementId')->willReturn($incrementId); + $shipment->method('getEntityId')->willReturn(1); + $shipment->method('getCreatedAt')->willReturn('2026-02-01 10:00:00'); + $shipment->method('getItems')->willReturn($items); + $shipment->method('getAllTracks')->willReturn($tracks); + + return $shipment; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/OrderToOrderResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/OrderToOrderResponseTest.php new file mode 100644 index 0000000..92d5b36 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/OrderToOrderResponseTest.php @@ -0,0 +1,184 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\UcpSpec\Api\Shopping\OrderResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\OrderLineItemInterface; +use Magebit\UcpSpec\Api\UcpResponseOrderSchemaInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\OrderResponse; +use Magebit\UcpSpec\Data\Shopping\OrderResponseFulfillment; +use Magebit\UcpSpec\Data\Shopping\Types\OrderLineItem; +use Magebit\UcpSpec\Data\UcpResponseOrderSchema; +use Magebit\UniversalCommerce\Api\ServiceInterface; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\Discovery\ServiceRegistry; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderItemToOrderLineItem; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToAdjustments; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToFulfillment; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToOrderResponse; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToTotalsResponse; +use Magento\Sales\Api\Data\OrderItemInterface; +use Magento\Sales\Model\Order; +use PHPUnit\Framework\TestCase; + +class OrderToOrderResponseTest extends TestCase +{ + /** + * The pictures for every line are looked up once for the order, not once per line, and each line + * gets the one belonging to its own product. + * + * @return void + */ + public function testPicturesAreLookedUpOncePerOrder(): void + { + $passedImageUrls = []; + + $lineItemConverter = $this->createMock(OrderItemToOrderLineItem::class); + $lineItemConverter->expects($this->once()) + ->method('imageUrls') + ->willReturn([10 => 'https://example.com/10.jpg']); + $lineItemConverter->expects($this->exactly(3)) + ->method('convert') + ->willReturnCallback( + function ( + OrderItemInterface $orderItem, + string $currencyCode, + string $lineItemId, + ?string $parentId = null, + ?string $imageUrl = null + ) use (&$passedImageUrls): OrderLineItemInterface { + $passedImageUrls[$lineItemId] = $imageUrl; + + return new OrderLineItem(); + } + ); + + $items = [ + $this->orderItem(1, 10), + $this->orderItem(2, 20), + $this->orderItem(3, 10), + ]; + + $lineItems = $this->converter($lineItemConverter)->getLineItems( + $items, + [1 => 'line-1', 2 => 'line-2', 3 => 'line-3'], + 'USD' + ); + + $this->assertCount(3, $lineItems); + $this->assertSame([ + 'line-1' => 'https://example.com/10.jpg', + 'line-2' => null, + 'line-3' => 'https://example.com/10.jpg', + ], $passedImageUrls); + } + + /** + * The order answers to the checkout session, which nobody can guess. The store's own order + * number runs in sequence, so anyone could count up to someone else's; it is only a label. + * + * @return void + */ + public function testOrderIsAddressedByCheckoutSessionAndLabelledByOrderNumber(): void + { + $response = $this->fullConverter()->convert($this->order(), 'session-abc'); + + $this->assertSame('session-abc', $response->getId()); + $this->assertSame('session-abc', $response->getCheckoutId()); + $this->assertSame('000000123', $response->getLabel()); + } + + /** + * @param OrderItemToOrderLineItem $lineItemConverter + * @return OrderToOrderResponse + */ + private function converter(OrderItemToOrderLineItem $lineItemConverter): OrderToOrderResponse + { + return new OrderToOrderResponse( + $this->createMock(OrderResponseInterfaceFactory::class), + $this->createMock(UcpResponseOrderSchemaInterfaceFactory::class), + $lineItemConverter, + $this->createMock(OrderToTotalsResponse::class), + $this->createMock(OrderToFulfillment::class), + $this->createMock(OrderToAdjustments::class), + $this->createMock(ServiceRegistry::class), + $this->createMock(Config::class) + ); + } + + /** + * Wired well enough for a whole convert() call: real response objects out of the factories, and + * stubs for everything the identifiers do not depend on. + * + * @return OrderToOrderResponse + */ + private function fullConverter(): OrderToOrderResponse + { + $responseFactory = $this->createMock(OrderResponseInterfaceFactory::class); + $responseFactory->method('create')->willReturnCallback( + static fn (): OrderResponse => new OrderResponse() + ); + + $ucpFactory = $this->createMock(UcpResponseOrderSchemaInterfaceFactory::class); + $ucpFactory->method('create')->willReturnCallback( + static fn (array $args = []): UcpResponseOrderSchema => new UcpResponseOrderSchema($args['data'] ?? []) + ); + + $registry = $this->createMock(ServiceRegistry::class); + $registry->method('getService')->willReturn($this->createMock(ServiceInterface::class)); + + $fulfillmentConverter = $this->createMock(OrderToFulfillment::class); + $fulfillmentConverter->method('convert')->willReturn(new OrderResponseFulfillment()); + + return new OrderToOrderResponse( + $responseFactory, + $ucpFactory, + $this->createMock(OrderItemToOrderLineItem::class), + $this->createMock(OrderToTotalsResponse::class), + $fulfillmentConverter, + $this->createMock(OrderToAdjustments::class), + $registry, + $this->createMock(Config::class) + ); + } + + /** + * @return Order + */ + private function order(): Order + { + $order = $this->createMock(Order::class); + $order->method('getIncrementId')->willReturn('000000123'); + $order->method('getEntityId')->willReturn(7); + $order->method('getStoreId')->willReturn(1); + $order->method('getOrderCurrencyCode')->willReturn('USD'); + $order->method('getAllItems')->willReturn([]); + + return $order; + } + + /** + * @param int $itemId + * @param int $productId + * @return OrderItemInterface + */ + private function orderItem(int $itemId, int $productId): OrderItemInterface + { + $item = $this->createMock(OrderItemInterface::class); + $item->method('getItemId')->willReturn($itemId); + $item->method('getProductId')->willReturn($productId); + $item->method('getParentItemId')->willReturn(null); + + return $item; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/OrderToTotalsResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/OrderToTotalsResponseTest.php new file mode 100644 index 0000000..0db254c --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/OrderToTotalsResponseTest.php @@ -0,0 +1,168 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\Types\TotalResponse; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToTotalsResponse; +use Magento\Sales\Api\Data\OrderInterface; +use PHPUnit\Framework\TestCase; + +class OrderToTotalsResponseTest extends TestCase +{ + /** @var OrderToTotalsResponse */ + private OrderToTotalsResponse $converter; + + /** + * @return void + */ + protected function setUp(): void + { + $factory = $this->createMock(TotalResponseInterfaceFactory::class); + $factory->method('create')->willReturnCallback(fn (): TotalResponse => new TotalResponse()); + + $this->converter = new OrderToTotalsResponse($factory, new MinorUnits()); + } + + /** + * Shipping excluding tax plus the whole tax figure is what makes the entries add up; taking shipping + * inclusive of tax would count shipping tax twice wherever shipping is taxed. + * + * @return void + */ + public function testTheEntriesSumToTheGrandTotal(): void + { + $totals = $this->convert([ + 'subtotal' => 100.00, + 'discount_amount' => -15.00, + 'shipping_amount' => 7.50, + 'tax_amount' => 8.44, + 'grand_total' => 100.94, + ]); + + $sum = 0; + + foreach ($totals as $type => $amount) { + if ($type !== TotalTypeInterface::TYPE_TOTAL) { + $sum += $amount; + } + } + + $this->assertSame($totals[TotalTypeInterface::TYPE_TOTAL], $sum); + } + + /** + * @return void + */ + public function testTheDiscountIsNegative(): void + { + $totals = $this->convert([ + 'subtotal' => 100.00, + 'discount_amount' => -15.00, + 'grand_total' => 85.00, + ]); + + $this->assertSame(-1500, $totals[TotalTypeInterface::TYPE_DISCOUNT]); + } + + /** + * An order with no shipping charge and no tax must not report zero entries, which would read as + * charges that applied. + * + * @return void + */ + public function testEmptyChargesAreOmitted(): void + { + $totals = $this->convert(['subtotal' => 50.00, 'grand_total' => 50.00]); + + $this->assertSame( + [TotalTypeInterface::TYPE_SUBTOTAL, TotalTypeInterface::TYPE_TOTAL], + array_keys($totals) + ); + } + + /** + * The spec requires exactly one subtotal and one total on every breakdown. + * + * @return void + */ + public function testSubtotalAndTotalAreAlwaysPresentExactlyOnce(): void + { + $response = $this->converter->convert($this->order([])); + $types = array_map(fn (TotalResponseInterface $t): string => $t->getType(), $response); + $counts = array_count_values($types); + + $this->assertSame(1, $counts[TotalTypeInterface::TYPE_SUBTOTAL]); + $this->assertSame(1, $counts[TotalTypeInterface::TYPE_TOTAL]); + } + + /** + * The shipping description is what the buyer chose, so it beats a generic label. + * + * @return void + */ + public function testTheShippingEntryIsLabelledWithTheChosenMethod(): void + { + $response = $this->converter->convert( + $this->order(['shipping_amount' => 5.00], shippingDescription: 'Flat Rate - Fixed') + ); + + foreach ($response as $total) { + if ($total->getType() === TotalTypeInterface::TYPE_FULFILLMENT) { + $this->assertSame('Flat Rate - Fixed', $total->getDisplayText()); + + return; + } + } + + $this->fail('no fulfillment total was emitted'); + } + + /** + * @param array $amounts + * @return array + */ + private function convert(array $amounts): array + { + $result = []; + + foreach ($this->converter->convert($this->order($amounts)) as $total) { + $result[$total->getType()] = $total->getAmount(); + } + + return $result; + } + + /** + * @param array $amounts + * @param string|null $shippingDescription + * @return OrderInterface + */ + private function order(array $amounts, ?string $shippingDescription = null): OrderInterface + { + $order = $this->createMock(OrderInterface::class); + $order->method('getOrderCurrencyCode')->willReturn('USD'); + $order->method('getSubtotal')->willReturn($amounts['subtotal'] ?? 0.0); + $order->method('getDiscountAmount')->willReturn($amounts['discount_amount'] ?? 0.0); + $order->method('getShippingAmount')->willReturn($amounts['shipping_amount'] ?? 0.0); + $order->method('getTaxAmount')->willReturn($amounts['tax_amount'] ?? 0.0); + $order->method('getGrandTotal')->willReturn($amounts['grand_total'] ?? 0.0); + $order->method('getDiscountDescription')->willReturn(null); + $order->method('getShippingDescription')->willReturn($shippingDescription); + + return $order; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/PriceConverterTest.php b/Test/Unit/Model/Service/Shopping/Converter/PriceConverterTest.php deleted file mode 100644 index f9b13fc..0000000 --- a/Test/Unit/Model/Service/Shopping/Converter/PriceConverterTest.php +++ /dev/null @@ -1,88 +0,0 @@ - - * @license MIT - */ - -declare(strict_types=1); - -namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; - -use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\PriceConverter; -use PHPUnit\Framework\TestCase; - -class PriceConverterTest extends TestCase -{ - /** - * @var PriceConverter - */ - private PriceConverter $converter; - - /** - * @return void - */ - protected function setUp(): void - { - $this->converter = new PriceConverter(); - } - - /** - * @dataProvider convertDataProvider - * @param float $amount - * @param string $currencyCode - * @param int $expected - * @return void - */ - public function testConvert(float $amount, string $currencyCode, int $expected): void - { - $this->assertSame($expected, $this->converter->convert($amount, $currencyCode)); - } - - /** - * @return array - */ - public static function convertDataProvider(): array - { - return [ - // Values the previous float-multiplication implementation truncated. - 'usd 19.99 truncated to 1998' => [19.99, 'USD', 1999], - 'usd 0.29 truncated to 28' => [0.29, 'USD', 29], - 'usd 1.15 truncated to 114' => [1.15, 'USD', 115], - 'usd 2.675 truncated to 267' => [2.675, 'USD', 268], - 'usd 1000.005 truncated to 100000' => [1000.005, 'USD', 100001], - - 'usd zero' => [0.0, 'USD', 0], - 'usd whole amount' => [10.0, 'USD', 1000], - 'usd half rounds up' => [0.005, 'USD', 1], - 'usd extra precision rounds up' => [123.456, 'USD', 12346], - - // Discounts arrive as negative amounts; rounding is half away from zero. - 'usd negative' => [-19.99, 'USD', -1999], - 'usd negative sub-unit' => [-0.29, 'USD', -29], - 'usd negative half rounds away from zero' => [-2.675, 'USD', -268], - - // ISO 4217 exponent 0 — the minor unit is the major unit. - 'jpy whole amount' => [1000.0, 'JPY', 1000], - 'jpy rounds down' => [1000.4, 'JPY', 1000], - 'jpy rounds up' => [1000.5, 'JPY', 1001], - 'jpy negative' => [-1000.0, 'JPY', -1000], - 'krw whole amount' => [50000.0, 'KRW', 50000], - 'vnd whole amount' => [25000.0, 'VND', 25000], - - // ISO 4217 exponent 3. - 'kwd three decimals' => [1.005, 'KWD', 1005], - 'kwd pads to three decimals' => [19.99, 'KWD', 19990], - 'kwd sub-unit rounds up' => [0.0005, 'KWD', 1], - 'kwd negative' => [-1.005, 'KWD', -1005], - 'bhd three decimals' => [12.345, 'BHD', 12345], - - 'eur defaults to two' => [19.99, 'EUR', 1999], - 'unknown code defaults to two' => [19.99, 'XYZ', 1999], - 'lowercase currency code' => [19.99, 'usd', 1999], - ]; - } -} diff --git a/Test/Unit/Model/Service/Shopping/Converter/ProductToUcpProductTest.php b/Test/Unit/Model/Service/Shopping/Converter/ProductToUcpProductTest.php new file mode 100644 index 0000000..08978d5 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/ProductToUcpProductTest.php @@ -0,0 +1,290 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\DescriptionInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\MediaInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\PriceInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\PriceRangeInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\ProductInterface; +use Magebit\UcpSpec\Api\Shopping\Types\ProductInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\VariantAvailabilityInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\VariantInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\Types\Description; +use Magebit\UcpSpec\Data\Shopping\Types\Media; +use Magebit\UcpSpec\Data\Shopping\Types\Price; +use Magebit\UcpSpec\Data\Shopping\Types\PriceRange; +use Magebit\UcpSpec\Data\Shopping\Types\Product; +use Magebit\UcpSpec\Data\Shopping\Types\Variant; +use Magebit\UcpSpec\Data\Shopping\Types\VariantAvailability; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\ProductToUcpProduct; +use Magebit\AgenticCore\Model\Stock\Availability; +use Magento\Catalog\Model\Product as MagentoProduct; +use Magento\Framework\Pricing\PriceCurrencyInterface; +use PHPUnit\Framework\TestCase; + +class ProductToUcpProductTest extends TestCase +{ + /** + * @return void + */ + public function testCarriesTheRequiredFields(): void + { + $product = $this->convert(); + + $this->assertSame('ucp-simple', $product->getId()); + $this->assertSame('A Simple Product', $product->getTitle()); + $this->assertNotNull($product->getDescription()); + $this->assertNotNull($product->getPriceRange()); + $this->assertNotEmpty($product->getVariants()); + } + + /** + * A product with no children is its own single variant, so `variants` is never empty — the spec + * requires it and an agent buys a variant, not a product. + * + * @return void + */ + public function testASimpleProductIsItsOwnVariant(): void + { + $variants = $this->convert()->getVariants(); + + $this->assertCount(1, $variants); + $this->assertSame('ucp-simple', $variants[0]->getId()); + $this->assertSame('ucp-simple', $variants[0]->getSku()); + } + + /** + * @return void + */ + public function testPricesAreMinorUnitsWithACurrency(): void + { + $price = $this->convert()->getPriceRange()->getMin(); + + $this->assertSame(2000, $price->getAmount()); + $this->assertSame('USD', $price->getCurrency()); + } + + /** + * A single-price product still reports a range, with both ends the same. + * + * @return void + */ + public function testTheRangeCollapsesWhenThereIsOnePrice(): void + { + $range = $this->convert()->getPriceRange(); + + $this->assertSame($range->getMin()->getAmount(), $range->getMax()->getAmount()); + } + + /** + * @return void + */ + public function testAnInStockProductIsAvailable(): void + { + $availability = $this->convert()->getVariants()[0]->getAvailability(); + + $this->assertTrue($availability->getAvailable()); + $this->assertSame(ProductToUcpProduct::STATUS_IN_STOCK, $availability->getStatus()); + } + + /** + * @return void + */ + public function testAnOutOfStockProductIsNotAvailable(): void + { + $availability = $this->convert(isSalable: false)->getVariants()[0]->getAvailability(); + + $this->assertFalse($availability->getAvailable()); + $this->assertSame(ProductToUcpProduct::STATUS_OUT_OF_STOCK, $availability->getStatus()); + } + + /** + * The description is a typed object with a plain form, not a bare string. + * + * @return void + */ + public function testTheDescriptionCarriesAPlainForm(): void + { + $this->assertSame('Plain words.', $this->convert()->getDescription()->getPlain()); + } + + /** + * A product with no description still needs one: the field is required. + * + * @return void + */ + public function testAProductWithNoDescriptionStillReportsOne(): void + { + $description = $this->convert(description: null)->getDescription(); + + $this->assertNotNull($description); + $this->assertSame('', $description->getPlain()); + } + + /** + * A page reads everyone's stock in one go and leaves the answer on the product, so the converter + * must use that rather than asking again. + * + * @return void + */ + public function testStockAlreadyReadForThePageIsUsedAsIs(): void + { + $asked = false; + $product = $this->productCarryingStock(false); + + $converter = $this->converter(true, function () use (&$asked): bool { + $asked = true; + + return true; + }); + + $availability = $converter->convert($product, 'USD')->getVariants()[0]->getAvailability(); + + $this->assertFalse($availability->getAvailable()); + $this->assertFalse($asked); + } + + /** + * @return void + */ + public function testAProductCarryingStockIsReportedInStock(): void + { + $converter = $this->converter(); + $availability = $converter->convert($this->productCarryingStock(true), 'USD') + ->getVariants()[0] + ->getAvailability(); + + $this->assertTrue($availability->getAvailable()); + } + + /** + * The stock read with the page comes out of the database as text, so "0" has to mean out of + * stock rather than "some value is set". + * + * @param string $loaded + * @param bool $expected + * @dataProvider stockAsText + * @return void + */ + public function testStockReadAsTextIsUnderstood(string $loaded, bool $expected): void + { + $availability = $this->converter() + ->convert($this->productCarryingStock($loaded), 'USD') + ->getVariants()[0] + ->getAvailability(); + + $this->assertSame($expected, $availability->getAvailable()); + } + + /** + * @return array + */ + public static function stockAsText(): array + { + return [ + 'in stock' => ['1', true], + 'out of stock' => ['0', false], + ]; + } + + /** + * @param bool|string $isSalable As a boolean, or as the text the database hands back + * @return MagentoProduct + */ + private function productCarryingStock(bool|string $isSalable): MagentoProduct + { + $product = $this->getMockBuilder(MagentoProduct::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSku', 'getName', 'getTypeId', 'getFinalPrice', 'getData']) + ->getMock(); + $product->method('getSku')->willReturn('ucp-simple'); + $product->method('getName')->willReturn('A Simple Product'); + $product->method('getTypeId')->willReturn('simple'); + $product->method('getFinalPrice')->willReturn(20.0); + $product->method('getData')->willReturnCallback( + static fn (string $key): mixed => $key === 'is_salable' ? $isSalable : null + ); + + return $product; + } + + /** + * @param bool $isSalable + * @param string|null $description + * @return ProductInterface + */ + private function convert(bool $isSalable = true, ?string $description = 'Plain words.'): ProductInterface + { + $product = $this->getMockBuilder(MagentoProduct::class) + ->disableOriginalConstructor() + ->onlyMethods(['getSku', 'getName', 'getTypeId', 'getFinalPrice', 'getData']) + ->getMock(); + $product->method('getSku')->willReturn('ucp-simple'); + $product->method('getName')->willReturn('A Simple Product'); + $product->method('getTypeId')->willReturn('simple'); + $product->method('getFinalPrice')->willReturn(20.0); + $product->method('getData')->willReturnCallback( + static fn (string $key): mixed => $key === 'description' ? $description : null + ); + + $currency = $this->createMock(PriceCurrencyInterface::class); + $currency->method('getCurrencySymbol')->willReturn('$'); + + return $this->converter($isSalable)->convert($product, 'USD'); + } + + /** + * @param bool $isSalable + * @param callable|null $onAsk Called instead, when the test wants to know whether stock was asked + * @return ProductToUcpProduct + */ + private function converter(bool $isSalable = true, ?callable $onAsk = null): ProductToUcpProduct + { + $stock = $this->createMock(Availability::class); + + if ($onAsk === null) { + $stock->method('isSalable')->willReturn($isSalable); + } else { + $stock->method('isSalable')->willReturnCallback($onAsk); + } + + return new ProductToUcpProduct( + $this->factoryFor(ProductInterfaceFactory::class, Product::class), + $this->factoryFor(VariantInterfaceFactory::class, Variant::class), + $this->factoryFor(PriceInterfaceFactory::class, Price::class), + $this->factoryFor(PriceRangeInterfaceFactory::class, PriceRange::class), + $this->factoryFor(DescriptionInterfaceFactory::class, Description::class), + $this->factoryFor(MediaInterfaceFactory::class, Media::class), + $this->factoryFor(VariantAvailabilityInterfaceFactory::class, VariantAvailability::class), + new MinorUnits(), + $stock + ); + } + + /** + * @param class-string $factoryClass + * @param class-string $concrete + * @return mixed + */ + private function factoryFor(string $factoryClass, string $concrete): mixed + { + $factory = $this->createMock($factoryClass); + $factory->method('create')->willReturnCallback( + static fn (array $args = []): object => new $concrete($args['data'] ?? []) + ); + + return $factory; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/QuoteItemToLineItemResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/QuoteItemToLineItemResponseTest.php new file mode 100644 index 0000000..ab0bf30 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/QuoteItemToLineItemResponseTest.php @@ -0,0 +1,144 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\UcpSpec\Api\Shopping\Types\ItemResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\LineItemResponseInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\Types\ItemResponse; +use Magebit\UcpSpec\Data\Shopping\Types\LineItemResponse; +use Magebit\UcpSpec\Data\Shopping\Types\TotalResponse; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteItemToLineItemResponse; +use Magento\Catalog\Helper\Image as ImageHelper; +use Magento\Quote\Api\Data\CurrencyInterface; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\Quote\Item as QuoteItem; +use PHPUnit\Framework\TestCase; + +class QuoteItemToLineItemResponseTest extends TestCase +{ + /** @var QuoteItemToLineItemResponse */ + private QuoteItemToLineItemResponse $converter; + + /** + * @return void + */ + protected function setUp(): void + { + $lineItemFactory = $this->createMock(LineItemResponseInterfaceFactory::class); + $lineItemFactory->method('create')->willReturnCallback(fn (): LineItemResponse => new LineItemResponse()); + + $itemFactory = $this->createMock(ItemResponseInterfaceFactory::class); + $itemFactory->method('create')->willReturnCallback(fn (): ItemResponse => new ItemResponse()); + + $totalFactory = $this->createMock(TotalResponseInterfaceFactory::class); + $totalFactory->method('create')->willReturnCallback(fn (): TotalResponse => new TotalResponse()); + + $imageHelper = $this->createMock(ImageHelper::class); + $imageHelper->method('init')->willReturnSelf(); + $imageHelper->method('getUrl')->willReturn('https://merchant.test/media/sku.jpg'); + + $this->converter = new QuoteItemToLineItemResponse( + $lineItemFactory, + $itemFactory, + $totalFactory, + $imageHelper, + new MinorUnits() + ); + } + + /** + * A line item's totals are the same `total_resp` entries as the checkout's, so `items_discount` + * carries the same `exclusiveMaximum: 0` and must be negative. + * + * @return void + */ + public function testTheItemDiscountIsEmittedAsANegativeAmount(): void + { + $totals = $this->totalsOf($this->quoteItem(discountAmount: 5.00)); + + $this->assertSame(-500, $totals[TotalTypeInterface::TYPE_ITEMS_DISCOUNT]); + } + + /** + * @return void + */ + public function testTheRemainingItemTotalsStayPositive(): void + { + $totals = $this->totalsOf($this->quoteItem(discountAmount: 5.00)); + + $this->assertSame(4000, $totals[TotalTypeInterface::TYPE_SUBTOTAL]); + $this->assertSame(3850, $totals[TotalTypeInterface::TYPE_TOTAL]); + } + + /** + * An item with nothing taken off must not emit a zero discount, which would read as a discount + * that applied and is also not a legal negative amount. + * + * @return void + */ + public function testAnUndiscountedItemEmitsNoDiscountEntry(): void + { + $totals = $this->totalsOf($this->quoteItem(discountAmount: 0.00)); + + $this->assertArrayNotHasKey(TotalTypeInterface::TYPE_ITEMS_DISCOUNT, $totals); + } + + /** + * @param QuoteItem $quoteItem + * @return array + */ + private function totalsOf(QuoteItem $quoteItem): array + { + $result = []; + + foreach ($this->converter->convertTotals($quoteItem) as $total) { + /** @var TotalResponseInterface $total */ + $result[$total->getType()] = $total->getAmount(); + } + + return $result; + } + + /** + * @param float $discountAmount + * @return QuoteItem + */ + private function quoteItem(float $discountAmount): QuoteItem + { + $currency = $this->createMock(CurrencyInterface::class); + $currency->method('getStoreCurrencyCode')->willReturn('USD'); + + $quote = $this->getMockBuilder(Quote::class) + ->disableOriginalConstructor() + ->onlyMethods(['getCurrency']) + ->getMock(); + $quote->method('getCurrency')->willReturn($currency); + + // Row totals and the item discount resolve through __call, so they must be added. + $item = $this->getMockBuilder(QuoteItem::class) + ->disableOriginalConstructor() + ->onlyMethods(['getQuote']) + ->addMethods(['getRowTotal', 'getDiscountAmount', 'getRowTotalInclTax']) + ->getMock(); + $item->method('getQuote')->willReturn($quote); + $item->method('getRowTotal')->willReturn(40.00); + $item->method('getDiscountAmount')->willReturn($discountAmount); + $item->method('getRowTotalInclTax')->willReturn(40.00 - $discountAmount + 3.50); + + return $item; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/QuoteToBuyerResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/QuoteToBuyerResponseTest.php new file mode 100644 index 0000000..19e9b92 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/QuoteToBuyerResponseTest.php @@ -0,0 +1,130 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Model\Buyer\BuyerResolver; +use Magebit\UcpSpec\Api\Shopping\BuyerConsentResponseConsentInterfaceFactory; +use Magebit\UcpSpec\Api\Shopping\Types\BuyerInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\BuyerConsentResponseConsent; +use Magebit\UniversalCommerce\Api\Service\Shopping\BuyerWithConsentInterface; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToBuyerResponse; +use Magebit\UniversalCommerce\Model\Spec\Schemas\Shopping\BuyerWithConsent; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\Quote\Address; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class QuoteToBuyerResponseTest extends TestCase +{ + private QuoteToBuyerResponse $converter; + + /** + * @return void + */ + protected function setUp(): void + { + $buyerFactory = $this->createMock(BuyerInterfaceFactory::class); + $buyerFactory->method('create')->willReturnCallback( + static fn (): BuyerWithConsent => new BuyerWithConsent() + ); + + $consentFactory = $this->createMock(BuyerConsentResponseConsentInterfaceFactory::class); + $consentFactory->method('create')->willReturnCallback( + static fn (array $arguments = []): BuyerConsentResponseConsent + => new BuyerConsentResponseConsent($arguments['data'] ?? []) + ); + + $this->converter = new QuoteToBuyerResponse( + $buyerFactory, + new BuyerResolver(), + $consentFactory + ); + } + + /** + * @return void + */ + public function testTheRecordedConsentIsReportedBackFlagByFlag(): void + { + $buyer = $this->converter->convert($this->quote('ada@example.test'), [ + 'marketing' => true, + 'analytics' => false, + 'sale_of_data' => false, + ]); + + $this->assertInstanceOf(BuyerWithConsentInterface::class, $buyer); + $consent = $buyer->getConsent(); + $this->assertNotNull($consent); + $this->assertTrue($consent->getMarketing()); + $this->assertFalse($consent->getAnalytics()); + $this->assertFalse($consent->getSaleOfData()); + } + + /** + * @return void + */ + public function testABuyerWithNoRecordedConsentCarriesNone(): void + { + $buyer = $this->converter->convert($this->quote('ada@example.test')); + + $this->assertInstanceOf(BuyerWithConsentInterface::class, $buyer); + $this->assertNull($buyer->getConsent()); + } + + /** + * @return void + */ + public function testAnEmptyBuyerIsOmittedRatherThanSentEmpty(): void + { + $this->assertNull($this->converter->convert($this->quote(null))); + } + + /** + * Consent is a buyer fact of its own, so a checkout that has only consent still reports a buyer. + * + * @return void + */ + public function testConsentAloneIsEnoughToReportABuyer(): void + { + $buyer = $this->converter->convert($this->quote(null), ['marketing' => true]); + + $this->assertInstanceOf(BuyerWithConsentInterface::class, $buyer); + $this->assertTrue($buyer->getConsent()?->getMarketing()); + } + + /** + * @param string|null $email + * @return Quote&MockObject + */ + private function quote(?string $email): Quote&MockObject + { + $address = $this->getMockBuilder(Address::class) + ->disableOriginalConstructor() + ->onlyMethods(['getFirstname', 'getLastname', 'getEmail', 'getTelephone']) + ->getMock(); + + $quote = $this->getMockBuilder(Quote::class) + ->disableOriginalConstructor() + // All three customer accessors are magic on Quote rather than declared. + ->addMethods(['getCustomerFirstname', 'getCustomerLastname', 'getCustomerEmail']) + ->onlyMethods(['getBillingAddress', 'getShippingAddress']) + ->getMock(); + $quote->method('getCustomerFirstname')->willReturn(null); + $quote->method('getCustomerLastname')->willReturn(null); + $quote->method('getCustomerEmail')->willReturn($email); + $quote->method('getBillingAddress')->willReturn($address); + $quote->method('getShippingAddress')->willReturn($address); + + return $quote; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/QuoteToCartResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/QuoteToCartResponseTest.php new file mode 100644 index 0000000..5fa4e62 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/QuoteToCartResponseTest.php @@ -0,0 +1,119 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInfoInterface; +use Magebit\UcpSpec\Api\UcpResponseCartSchemaInterface; +use Magebit\UcpSpec\Data\Shopping\Types\Message; +use Magebit\UcpSpec\Data\Shopping\Types\MessageInfo; +use Magebit\UcpSpec\Data\UcpResponseCartSchema; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\CartMessageDowngrade; +use PHPUnit\Framework\TestCase; + +class QuoteToCartResponseTest extends TestCase +{ + /** + * A cart is pre-purchase exploration, so a missing delivery address is a warning about what checkout + * will need — not an error about the cart. + * + * @return void + */ + public function testCheckoutValidationErrorsBecomeInformational(): void + { + $error = new Message([ + MessageInterface::KEY_TYPE => MessageInterface::TYPE_ERROR, + MessageInterface::KEY_CODE => 'missing', + MessageInterface::KEY_CONTENT => 'City is required', + MessageInterface::KEY_SEVERITY => MessageInterface::SEVERITY_REQUIRES_BUYER_INPUT, + MessageInterface::KEY_PATH => '$.fulfillment.methods[0].destination.address_locality', + ]); + + $messages = $this->downgrade()->apply([$error]); + + $this->assertCount(1, $messages); + $this->assertSame(MessageInfoInterface::TYPE_INFO, $messages[0]->getType()); + } + + /** + * @return void + */ + public function testTheOriginalContentAndPathSurvive(): void + { + $error = new Message([ + MessageInterface::KEY_TYPE => MessageInterface::TYPE_ERROR, + MessageInterface::KEY_CODE => 'missing', + MessageInterface::KEY_CONTENT => 'City is required', + MessageInterface::KEY_SEVERITY => MessageInterface::SEVERITY_REQUIRES_BUYER_INPUT, + MessageInterface::KEY_PATH => '$.buyer.first_name', + ]); + + $message = $this->downgrade()->apply([$error])[0]; + + $this->assertSame('City is required', $message->getContent()); + $this->assertSame('$.buyer.first_name', $message->getPath()); + } + + /** + * A message the cart itself raised is already informational and is passed through untouched. + * + * @return void + */ + public function testAnInformationalMessageIsLeftAlone(): void + { + $info = new MessageInfo([ + MessageInfoInterface::KEY_TYPE => MessageInfoInterface::TYPE_INFO, + MessageInfoInterface::KEY_CONTENT => 'Estimated totals', + ]); + + $messages = $this->downgrade()->apply([$info]); + + $this->assertSame($info, $messages[0]); + } + + /** + * @return void + */ + public function testNoMessagesStayNoMessages(): void + { + $this->assertSame([], $this->downgrade()->apply([])); + } + + /** + * `response_cart_schema` carries no payment handlers: there is no payment before checkout. + * + * @return void + */ + public function testTheCartUcpBlockCarriesNoPaymentHandlers(): void + { + $ucp = new UcpResponseCartSchema([ + UcpResponseCartSchemaInterface::KEY_VERSION => '2026-04-08', + UcpResponseCartSchemaInterface::KEY_CAPABILITIES => [], + ]); + + $this->assertFalse($ucp->has('payment_handlers')); + } + + /** + * @return CartMessageDowngrade + */ + private function downgrade(): CartMessageDowngrade + { + $factory = $this->createMock(\Magebit\UcpSpec\Api\Shopping\Types\MessageInfoInterfaceFactory::class); + $factory->method('create')->willReturnCallback( + static fn (array $args): MessageInfo => new MessageInfo($args['data']) + ); + + return new CartMessageDowngrade($factory); + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/QuoteToCheckoutResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/QuoteToCheckoutResponseTest.php new file mode 100644 index 0000000..3f510ab --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Converter/QuoteToCheckoutResponseTest.php @@ -0,0 +1,112 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; + +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; +use Magebit\UcpSpec\Api\Shopping\Types\OrderConfirmationInterface; +use Magebit\UcpSpec\Api\Shopping\Types\OrderConfirmationInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\Types\OrderConfirmation; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToCheckoutResponse; +use Magento\Sales\Api\Data\OrderInterface; +use Magento\Sales\Api\OrderRepositoryInterface; +use PHPUnit\Framework\TestCase; +use ReflectionClass; + +class QuoteToCheckoutResponseTest extends TestCase +{ + private const CHECKOUT_ID = 'session-abc'; + private const INCREMENT_ID = '000000123'; + private const ORDER_ID = 7; + + /** + * The confirmation answers to the checkout session, the same identifier the order endpoint takes. + * The store's own order number runs in sequence, so anyone could count up to someone else's; it + * is only a label. Built without the constructor so the test does not stand up seventeen + * collaborators, then given the four the confirmation is made from. + * + * @return void + */ + public function testConfirmationIsAddressedByCheckoutSessionAndLabelledByOrderNumber(): void + { + $confirmation = $this->confirmationFor(self::CHECKOUT_ID); + + $this->assertInstanceOf(OrderConfirmationInterface::class, $confirmation); + $this->assertSame(self::CHECKOUT_ID, $confirmation->getId()); + $this->assertSame(self::INCREMENT_ID, $confirmation->getLabel()); + } + + /** + * A session with no order behind it has nothing to confirm. + * + * @return void + */ + public function testSessionWithNoOrderHasNoConfirmation(): void + { + $this->assertNull($this->confirmationFor('session-without-order')); + } + + /** + * @param string $checkoutId Session to build the confirmation for + * @return OrderConfirmationInterface|null + */ + private function confirmationFor(string $checkoutId): ?OrderConfirmationInterface + { + $reflection = new ReflectionClass(QuoteToCheckoutResponse::class); + $converter = $reflection->newInstanceWithoutConstructor(); + + $order = $this->createMock(OrderInterface::class); + $order->method('getIncrementId')->willReturn(self::INCREMENT_ID); + $order->method('getStoreId')->willReturn(1); + + $orderRepository = $this->createMock(OrderRepositoryInterface::class); + $orderRepository->method('get')->willReturn($order); + + $orderLinkRepository = $this->createMock(OrderLinkRepositoryInterface::class); + $orderLinkRepository->method('findOrderId')->willReturnCallback( + static fn (string $scope, string $key): ?int => $key === self::CHECKOUT_ID ? self::ORDER_ID : null + ); + + $confirmationFactory = $this->createMock(OrderConfirmationInterfaceFactory::class); + $confirmationFactory->method('create')->willReturnCallback( + static fn (): OrderConfirmation => new OrderConfirmation() + ); + + $this->give($reflection, $converter, [ + 'orderLinkRepository' => $orderLinkRepository, + 'orderRepository' => $orderRepository, + 'orderConfirmationFactory' => $confirmationFactory, + 'config' => $this->createMock(Config::class), + ]); + + $method = $reflection->getMethod('getOrder'); + $method->setAccessible(true); + + return $method->invoke($converter, $checkoutId); + } + + /** + * @param ReflectionClass $reflection + * @param QuoteToCheckoutResponse $converter + * @param array $collaborators Property name to the mock it takes + * @return void + */ + private function give(ReflectionClass $reflection, QuoteToCheckoutResponse $converter, array $collaborators): void + { + foreach ($collaborators as $name => $collaborator) { + $property = $reflection->getProperty($name); + $property->setAccessible(true); + $property->setValue($converter, $collaborator); + } + } +} diff --git a/Test/Unit/Model/Service/Shopping/Converter/QuoteToDiscountResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/QuoteToDiscountResponseTest.php index 97b8507..cb05c84 100644 --- a/Test/Unit/Model/Service/Shopping/Converter/QuoteToDiscountResponseTest.php +++ b/Test/Unit/Model/Service/Shopping/Converter/QuoteToDiscountResponseTest.php @@ -16,7 +16,7 @@ use Magebit\UcpSpec\Api\Shopping\DiscountResponseDiscountsObjectInterfaceFactory; use Magebit\UcpSpec\Data\Shopping\DiscountResponseAppliedDiscount; use Magebit\UcpSpec\Data\Shopping\DiscountResponseDiscountsObject; -use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\PriceConverter; +use Magebit\AgenticCore\Model\Money\MinorUnits; use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToDiscountResponse; use Magento\Quote\Api\Data\CurrencyInterface; use Magento\Quote\Model\Quote; @@ -40,7 +40,7 @@ protected function setUp(): void $appliedFactory->method('create') ->willReturnCallback(fn (): DiscountResponseAppliedDiscount => new DiscountResponseAppliedDiscount()); - $this->converter = new QuoteToDiscountResponse($discountsFactory, $appliedFactory, new PriceConverter()); + $this->converter = new QuoteToDiscountResponse($discountsFactory, $appliedFactory, new MinorUnits()); } /** diff --git a/Test/Unit/Model/Service/Shopping/Converter/QuoteToFulfillmentResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/QuoteToFulfillmentResponseTest.php index 03b2d97..496fd88 100644 --- a/Test/Unit/Model/Service/Shopping/Converter/QuoteToFulfillmentResponseTest.php +++ b/Test/Unit/Model/Service/Shopping/Converter/QuoteToFulfillmentResponseTest.php @@ -12,6 +12,7 @@ namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; +use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentDestinationResponseInterface; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentDestinationResponseInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentGroupResponseInterfaceFactory; use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentMethodResponseInterface; @@ -25,16 +26,19 @@ use Magebit\UcpSpec\Data\Shopping\Types\FulfillmentOptionResponse; use Magebit\UcpSpec\Data\Shopping\Types\FulfillmentResponse; use Magebit\UcpSpec\Data\Shopping\Types\TotalResponse; -use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\PriceConverter; +use Magebit\AgenticCore\Model\Fulfillment\ShippingOption; +use Magebit\AgenticCore\Model\Fulfillment\ShippingOptionResolver; use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToFulfillmentResponse; use Magento\Quote\Model\Quote; use Magento\Quote\Model\Quote\Address; -use Magento\Quote\Model\Quote\Address\Rate; -use Magento\Quote\Api\Data\CurrencyInterface; use PHPUnit\Framework\TestCase; class QuoteToFulfillmentResponseTest extends TestCase { + private const AMOUNT_EXCL_TAX = 599; + private const TAX_AMOUNT = 126; + private const AMOUNT_INCL_TAX = 725; + private QuoteToFulfillmentResponse $converter; /** @@ -42,7 +46,29 @@ class QuoteToFulfillmentResponseTest extends TestCase */ protected function setUp(): void { - $this->converter = new QuoteToFulfillmentResponse( + $this->converter = $this->converterFor([ + new ShippingOption( + 'flatrate_flatrate', + 'Fixed', + 'Flat Rate', + 'Flat Rate', + self::AMOUNT_EXCL_TAX, + self::TAX_AMOUNT, + self::AMOUNT_INCL_TAX + ), + ]); + } + + /** + * @param ShippingOption[] $options Options the store offers + * @return QuoteToFulfillmentResponse + */ + private function converterFor(array $options): QuoteToFulfillmentResponse + { + $resolver = $this->createMock(ShippingOptionResolver::class); + $resolver->method('resolve')->willReturn($options); + + return new QuoteToFulfillmentResponse( $this->factory(FulfillmentResponseInterfaceFactory::class, FulfillmentResponse::class), $this->factory(FulfillmentMethodResponseInterfaceFactory::class, FulfillmentMethodResponse::class), $this->factory(FulfillmentGroupResponseInterfaceFactory::class, FulfillmentGroupResponse::class), @@ -52,7 +78,7 @@ protected function setUp(): void FulfillmentDestinationResponse::class ), $this->factory(TotalResponseInterfaceFactory::class, TotalResponse::class), - new PriceConverter() + $resolver ); } @@ -93,6 +119,7 @@ public function testSubmittedIdentifiersAreEchoedBack(): void public function testSubmittedSelectionsWinOverTheQuoteState(): void { $methods = $this->converter->getMethods($this->address(), ['1'], [ + 'destinations' => [['id' => 'agent_destination']], 'selected_destination_id' => 'agent_destination', 'groups' => [['id' => 'g1', 'selected_option_id' => 'flatrate_flatrate']], ]); @@ -101,6 +128,103 @@ public function testSubmittedSelectionsWinOverTheQuoteState(): void $this->assertSame('flatrate_flatrate', $methods[0]->getGroups()[0]->getSelectedOptionId()); } + /** + * A selection naming an option the store does not offer points at nothing, and reporting it tells + * the agent a shipping method is chosen when the order has none — which only surfaces later, as a + * refusal to place the order. + * + * @return void + */ + public function testASelectionNamingAnUnofferedOptionIsNotEchoed(): void + { + $methods = $this->converter->getMethods($this->address(), ['1'], [ + 'groups' => [['id' => 'g1', 'selected_option_id' => 'std-ship']], + ]); + + $this->assertNull($methods[0]->getGroups()[0]->getSelectedOptionId()); + } + + /** + * @return void + */ + public function testTheQuotesOwnMethodIsUsedWhenTheSubmittedOneIsNotOffered(): void + { + $methods = $this->converter->getMethods($this->address('flatrate_flatrate'), ['1'], [ + 'groups' => [['id' => 'g1', 'selected_option_id' => 'std-ship']], + ]); + + $this->assertSame('flatrate_flatrate', $methods[0]->getGroups()[0]->getSelectedOptionId()); + } + + /** + * @return void + */ + public function testAnUnofferedMethodOnTheQuoteIsNotEchoedEither(): void + { + $methods = $this->converter->getMethods($this->address('ups_ground'), ['1'], [ + 'groups' => [['id' => 'g1']], + ]); + + $this->assertNull($methods[0]->getGroups()[0]->getSelectedOptionId()); + } + + /** + * There is one address on the quote, so a selection naming something the agent never described has + * nothing to point at — the response names the destination it does list rather than echoing a + * reference that resolves to nothing. + * + * @return void + */ + public function testASelectionNamingAnUnlistedDestinationIsNotEchoed(): void + { + $methods = $this->converter->getMethods($this->address(), ['1'], [ + 'selected_destination_id' => 'never_submitted', + ]); + + $this->assertNotSame('never_submitted', $methods[0]->getSelectedDestinationId()); + $this->assertSame( + $methods[0]->getDestinations()[0]->getId(), + $methods[0]->getSelectedDestinationId() + ); + } + + /** + * The selection has to name a destination the response lists. Echoing the agent's identifier while + * renaming the destination to the quote address id left the reference pointing at nothing. + * + * @return void + */ + public function testTheSelectedDestinationIsOneOfTheListedDestinations(): void + { + $methods = $this->converter->getMethods($this->address(), ['1'], [ + 'destinations' => [['id' => 'agent_destination']], + 'selected_destination_id' => 'agent_destination', + ]); + + $this->assertSame( + [$methods[0]->getSelectedDestinationId()], + array_map( + fn (FulfillmentDestinationResponseInterface $destination): string => $destination->getId(), + $methods[0]->getDestinations() + ) + ); + } + + /** + * With no identifier submitted, the response invents one and points the selection at it. + * + * @return void + */ + public function testAnUnnamedDestinationStillMatchesTheSelection(): void + { + $methods = $this->converter->getMethods($this->address(), ['1']); + + $this->assertSame( + $methods[0]->getDestinations()[0]->getId(), + $methods[0]->getSelectedDestinationId() + ); + } + /** * A blank identifier is not a choice, so the merchant default still applies. * @@ -124,7 +248,67 @@ public function testComputedOptionsAreStillAttachedToASubmittedGroup(): void $this->assertCount(1, $options); $this->assertSame('flatrate_flatrate', $options[0]->getId()); - $this->assertSame(599, $options[0]->getTotals()[0]->getAmount()); + } + + /** + * The option's totals are a breakdown, so the parts have to add up to the total. Reporting only the + * incl-tax amount, under the `fulfillment` type, conflated the shipping charge with its tax. + * + * @return void + */ + public function testTheOptionsTotalsBreakTheCostDownAndAddUp(): void + { + $options = $this->converter->getMethods($this->address(), ['1'])[0]->getGroups()[0]->getOptions(); + + $amounts = []; + + foreach ($options[0]->getTotals() as $total) { + $amounts[(string) $total->getType()] = $total->getAmount(); + } + + $this->assertSame(self::AMOUNT_EXCL_TAX, $amounts['fulfillment']); + $this->assertSame(self::TAX_AMOUNT, $amounts['tax']); + $this->assertSame(self::AMOUNT_INCL_TAX, $amounts['total']); + $this->assertSame($amounts['total'], $amounts['fulfillment'] + $amounts['tax']); + } + + /** + * An agent that submitted a method with no destination has to see it come back, or it cannot tell + * whether the store understood the request. + * + * @return void + */ + public function testASubmittedMethodComesBackEvenWithNothingToOfferYet(): void + { + $converter = $this->converterFor([]); + + $methods = $converter->getMethods($this->address(), ['1'], [ + 'id' => 'agent_method_1', + 'type' => FulfillmentMethodResponseInterface::TYPE_SHIPPING, + ]); + + $this->assertCount(1, $methods); + $this->assertSame('agent_method_1', $methods[0]->getId()); + $this->assertSame([], $methods[0]->getGroups()[0]->getOptions() ?? []); + $this->assertNull($methods[0]->getGroups()[0]->getSelectedOptionId()); + } + + /** + * @return void + */ + public function testAnUntaxedOptionReportsNoTaxLine(): void + { + $converter = $this->converterFor([ + new ShippingOption('flatrate_flatrate', 'Fixed', null, 'Flat Rate', 500, 0, 500), + ]); + $options = $converter->getMethods($this->address(), ['1'])[0]->getGroups()[0]->getOptions(); + + $types = array_map( + static fn ($total): string => (string) $total->getType(), + $options[0]->getTotals() + ); + + $this->assertSame(['fulfillment', 'total'], $types); } /** @@ -143,39 +327,13 @@ private function factory(string $factoryClass, string $dtoClass): object /** * @return Address */ - private function address(): Address + private function address(?string $shippingMethod = null): Address { - $rate = $this->getMockBuilder(Rate::class) - ->disableOriginalConstructor() - ->addMethods([ - 'getCarrier', - 'getMethod', - 'getMethodTitle', - 'getCarrierTitle', - 'getPrice', - 'getErrorMessage', - ]) - ->getMock(); - $rate->method('getErrorMessage')->willReturn(null); - $rate->method('getCarrier')->willReturn('flatrate'); - $rate->method('getMethod')->willReturn('flatrate'); - $rate->method('getMethodTitle')->willReturn('Fixed'); - $rate->method('getCarrierTitle')->willReturn('Flat Rate'); - $rate->method('getPrice')->willReturn(5.99); - - $currency = $this->createMock(CurrencyInterface::class); - $currency->method('getStoreCurrencyCode')->willReturn('EUR'); - - $quote = $this->getMockBuilder(Quote::class) - ->disableOriginalConstructor() - ->onlyMethods(['getCurrency']) - ->getMock(); - $quote->method('getCurrency')->willReturn($currency); + $quote = $this->getMockBuilder(Quote::class)->disableOriginalConstructor()->onlyMethods([])->getMock(); $address = $this->getMockBuilder(Address::class) ->disableOriginalConstructor() ->onlyMethods([ - 'getAllShippingRates', 'getQuote', 'getCountryId', 'getStreet', @@ -183,12 +341,11 @@ private function address(): Address 'getShippingMethod', ]) ->getMock(); - $address->method('getAllShippingRates')->willReturn([$rate]); $address->method('getQuote')->willReturn($quote); $address->method('getCountryId')->willReturn('LV'); $address->method('getStreet')->willReturn(['Brivibas 1']); $address->method('getId')->willReturn(7); - $address->method('getShippingMethod')->willReturn(null); + $address->method('getShippingMethod')->willReturn($shippingMethod); return $address; } diff --git a/Test/Unit/Model/Service/Shopping/Converter/QuoteToTotalsResponseTest.php b/Test/Unit/Model/Service/Shopping/Converter/QuoteToTotalsResponseTest.php index 25d4e85..3470412 100644 --- a/Test/Unit/Model/Service/Shopping/Converter/QuoteToTotalsResponseTest.php +++ b/Test/Unit/Model/Service/Shopping/Converter/QuoteToTotalsResponseTest.php @@ -13,8 +13,10 @@ namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Converter; use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface; +use Magebit\UniversalCommerce\Api\Data\TotalTypeInterface; use Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterfaceFactory; -use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\PriceConverter; +use Magebit\AgenticCore\Model\Money\MinorUnits; +use Magebit\AgenticCore\Model\Total\TypeLabel; use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\QuoteToTotalsResponse; use Magebit\UcpSpec\Data\Shopping\Types\TotalResponse; use Magebit\UniversalCommerce\Test\Unit\Model\Stub\TotalRow; @@ -27,12 +29,12 @@ class QuoteToTotalsResponseTest extends TestCase { private const MAPPING = [ - 'subtotal' => TotalResponseInterface::TYPE_SUBTOTAL, - 'discount' => TotalResponseInterface::TYPE_DISCOUNT, - 'shipping_discount' => TotalResponseInterface::TYPE_DISCOUNT, - 'shipping' => TotalResponseInterface::TYPE_FULFILLMENT, - 'tax' => TotalResponseInterface::TYPE_TAX, - 'grand_total' => TotalResponseInterface::TYPE_TOTAL, + 'subtotal' => TotalTypeInterface::TYPE_SUBTOTAL, + 'discount' => TotalTypeInterface::TYPE_DISCOUNT, + 'shipping_discount' => TotalTypeInterface::TYPE_DISCOUNT, + 'shipping' => TotalTypeInterface::TYPE_FULFILLMENT, + 'tax' => TotalTypeInterface::TYPE_TAX, + 'grand_total' => TotalTypeInterface::TYPE_TOTAL, ]; /** @var QuoteToTotalsResponse */ @@ -46,15 +48,17 @@ protected function setUp(): void $factory = $this->createMock(TotalResponseInterfaceFactory::class); $factory->method('create')->willReturnCallback(fn (): TotalResponse => new TotalResponse()); - $this->converter = new QuoteToTotalsResponse($factory, new PriceConverter(), self::MAPPING); + $this->converter = new QuoteToTotalsResponse($factory, new MinorUnits(), new TypeLabel(), self::MAPPING); } /** - * Magento reports discounts negative; the spec types amount as minimum 0. + * The sign is intrinsic to the value: `total_resp` types discount amounts as `signed_amount` with + * `exclusiveMaximum: 0`, so a positive discount is a schema violation rather than a presentation + * choice. The 2026-01-23 snapshot typed every amount `minimum: 0`, which is why this reversed. * * @return void */ - public function testDiscountIsEmittedAsAPositiveAmount(): void + public function testDiscountIsEmittedAsANegativeAmount(): void { $totals = $this->convert([ ['subtotal', 'Subtotal', 100.00], @@ -62,22 +66,48 @@ public function testDiscountIsEmittedAsAPositiveAmount(): void ['grand_total', 'Grand Total', 85.00], ]); - $this->assertSame(1500, $totals[TotalResponseInterface::TYPE_DISCOUNT]); + $this->assertSame(-1500, $totals[TotalTypeInterface::TYPE_DISCOUNT]); } /** + * Magento's own sign is not consistent between total rows, so the type decides the sign rather + * than the value that arrived. + * + * @return void + */ + public function testAPositiveMagentoDiscountRowIsStillEmittedNegative(): void + { + $totals = $this->convert([ + ['subtotal', 'Subtotal', 100.00], + ['discount', 'Discount', 15.00], + ['grand_total', 'Grand Total', 85.00], + ]); + + $this->assertSame(-1500, $totals[TotalTypeInterface::TYPE_DISCOUNT]); + } + + /** + * `subtotal`, `fulfillment`, `tax` and `fee` carry `minimum: 0`, so only discounts may be negative. + * * @return void */ - public function testEveryAmountIsNonNegative(): void + public function testOnlyDiscountAmountsAreNegative(): void { $totals = $this->convert([ ['subtotal', 'Subtotal', 100.00], ['discount', 'Discount', -15.00], ['shipping_discount', 'Shipping Discount', -5.00], - ['grand_total', 'Grand Total', 80.00], + ['shipping', 'Shipping', 7.50], + ['tax', 'Tax', 4.25], + ['grand_total', 'Grand Total', 91.75], ]); - foreach ($totals as $amount) { + foreach ($totals as $type => $amount) { + if ($type === TotalTypeInterface::TYPE_DISCOUNT) { + $this->assertLessThan(0, $amount); + continue; + } + $this->assertGreaterThanOrEqual(0, $amount); } } @@ -96,7 +126,7 @@ public function testCodesSharingATypeAreSummed(): void ['grand_total', 'Grand Total', 80.00], ]); - $this->assertSame(2000, $totals[TotalResponseInterface::TYPE_DISCOUNT]); + $this->assertSame(-2000, $totals[TotalTypeInterface::TYPE_DISCOUNT]); } /** @@ -113,7 +143,7 @@ public function testUnmappedCodeIsDropped(): void ]); $this->assertSame( - [TotalResponseInterface::TYPE_SUBTOTAL, TotalResponseInterface::TYPE_TOTAL], + [TotalTypeInterface::TYPE_SUBTOTAL, TotalTypeInterface::TYPE_TOTAL], array_keys($totals) ); } @@ -131,8 +161,8 @@ public function testSubtotalAndTotalAppearExactlyOnce(): void $types = array_map(fn (TotalResponseInterface $t): string => $t->getType(), $response); - $this->assertSame(1, array_count_values($types)[TotalResponseInterface::TYPE_SUBTOTAL]); - $this->assertSame(1, array_count_values($types)[TotalResponseInterface::TYPE_TOTAL]); + $this->assertSame(1, array_count_values($types)[TotalTypeInterface::TYPE_SUBTOTAL]); + $this->assertSame(1, array_count_values($types)[TotalTypeInterface::TYPE_TOTAL]); } /** @@ -142,8 +172,8 @@ public function testMissingSubtotalFallsBackToTheAddress(): void { $totals = $this->convert([['grand_total', 'Grand Total', 42.00]], 37.50); - $this->assertSame(3750, $totals[TotalResponseInterface::TYPE_SUBTOTAL]); - $this->assertSame(4200, $totals[TotalResponseInterface::TYPE_TOTAL]); + $this->assertSame(3750, $totals[TotalTypeInterface::TYPE_SUBTOTAL]); + $this->assertSame(4200, $totals[TotalTypeInterface::TYPE_TOTAL]); } /** @@ -160,10 +190,10 @@ public function testEmissionOrderFollowsTheSpec(): void $this->assertSame( [ - TotalResponseInterface::TYPE_SUBTOTAL, - TotalResponseInterface::TYPE_DISCOUNT, - TotalResponseInterface::TYPE_TAX, - TotalResponseInterface::TYPE_TOTAL, + TotalTypeInterface::TYPE_SUBTOTAL, + TotalTypeInterface::TYPE_DISCOUNT, + TotalTypeInterface::TYPE_TAX, + TotalTypeInterface::TYPE_TOTAL, ], array_map(fn (TotalResponseInterface $t): string => $t->getType(), $response) ); @@ -185,12 +215,80 @@ private function convert(array $rows, float $addressSubtotal = 0.0): array return $result; } + /** + * Magento takes a coupon off the grand total without always emitting a discount row to go with it. + * Left alone the response shows a cheaper order with nothing to explain it. + * + * @return void + */ + public function testADiscountOnTheAddressIsReportedWhenMagentoEmitsNoRow(): void + { + $quote = $this->quote([ + ['subtotal', 'Subtotal', 20.00], + ['grand_total', 'Grand Total', 18.00], + ], addressDiscount: -2.00); + + $totals = $this->converter->convert($quote); + $discount = $this->byType($totals, TotalTypeInterface::TYPE_DISCOUNT); + + $this->assertNotNull($discount, 'the discount was dropped from the response'); + $this->assertSame(-200, $discount->getAmount()); + } + + /** + * @return void + */ + public function testTheAddressDiscountIsIgnoredWhenMagentoAlreadyEmittedOne(): void + { + $quote = $this->quote([ + ['subtotal', 'Subtotal', 20.00], + ['discount', 'Discount (10OFF)', -2.00], + ['grand_total', 'Grand Total', 18.00], + ], addressDiscount: -2.00); + + $discount = $this->byType($this->converter->convert($quote), TotalTypeInterface::TYPE_DISCOUNT); + + $this->assertNotNull($discount); + $this->assertSame(-200, $discount->getAmount()); + $this->assertSame('Discount (10OFF)', $discount->getDisplayText()); + } + + /** + * @return void + */ + public function testNoDiscountTotalWhenNothingWasDiscounted(): void + { + $quote = $this->quote([ + ['subtotal', 'Subtotal', 20.00], + ['grand_total', 'Grand Total', 20.00], + ]); + + $this->assertNull($this->byType($this->converter->convert($quote), TotalTypeInterface::TYPE_DISCOUNT)); + } + + /** + * @param array $totals + * @param string $type + * @return TotalResponseInterface|null + */ + private function byType(array $totals, string $type): ?TotalResponseInterface + { + foreach ($totals as $total) { + if ($total->getType() === $type) { + return $total; + } + } + + return null; + } + /** * @param array $rows * @param float $addressSubtotal + * @param float $addressDiscount Discount Magento stored on the address * @return Quote&MockObject */ - private function quote(array $rows, float $addressSubtotal = 0.0): Quote + private function quote(array $rows, float $addressSubtotal = 0.0, float $addressDiscount = 0.0): Quote { $totals = []; @@ -204,9 +302,10 @@ private function quote(array $rows, float $addressSubtotal = 0.0): Quote // getSubtotal() on a quote address resolves through __call, so it must be added. $address = $this->getMockBuilder(Address::class) ->disableOriginalConstructor() - ->addMethods(['getSubtotal']) + ->addMethods(['getSubtotal', 'getDiscountAmount']) ->getMock(); $address->method('getSubtotal')->willReturn($addressSubtotal); + $address->method('getDiscountAmount')->willReturn($addressDiscount); $quote = $this->getMockBuilder(Quote::class) ->disableOriginalConstructor() diff --git a/Test/Unit/Model/Service/Shopping/OrderHandlerTest.php b/Test/Unit/Model/Service/Shopping/OrderHandlerTest.php new file mode 100644 index 0000000..c9fe5f9 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/OrderHandlerTest.php @@ -0,0 +1,253 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping; + +use Magebit\UcpSpec\Api\Shopping\OrderResponseInterface; +use Magebit\UcpSpec\Api\Shopping\OrderResponseFulfillmentInterface; +use Magebit\UcpSpec\Api\Shopping\OrderUpdateRequestFulfillmentInterface; +use Magebit\UcpSpec\Api\Shopping\Types\FulfillmentEventInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +use Magebit\UcpSpec\Data\Shopping\Types\Message; +use Magebit\UcpSpec\Api\Shopping\OrderUpdateRequestInterface; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentInterface; +use Magebit\UniversalCommerce\Exception\UcpException; +use Magebit\UniversalCommerce\Model\Order\AdjustmentRecorder; +use Magebit\UniversalCommerce\Model\Order\SessionOrderLookup; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToOrderResponse; +use Magebit\UniversalCommerce\Model\Service\Shopping\OrderHandler; +use Magento\Sales\Model\Order; +use PHPUnit\Framework\TestCase; + +class OrderHandlerTest extends TestCase +{ + private const INCREMENT_ID = '000000123'; + private const ENTITY_ID = 42; + private const CHECKOUT_ID = 'checkout_session_placeholder_0001'; + + /** + * @return void + */ + public function testAnOrderFromThisProtocolIsServed(): void + { + $response = $this->handler(true)->getOrder(self::CHECKOUT_ID); + + $this->assertInstanceOf(OrderResponseInterface::class, $response); + } + + /** + * The store's order number runs in sequence, so anyone could count up to someone else's order. It + * addresses nothing here, and a caller holding one is told the same as a caller holding nothing. + * + * @return void + */ + public function testAnOrderNumberDoesNotReachAnOrder(): void + { + try { + $this->handler(true)->getOrder(self::INCREMENT_ID); + } catch (UcpException $exception) { + $this->assertSame(404, $exception->getStatusCode()); + $this->assertSame('not_found', $exception->getErrorCode()); + + return; + } + + $this->fail('an order number reached an order'); + } + + /** + * Orders placed through the storefront, or through the other protocol, have no session of this + * protocol behind them, so this protocol does not hand them out. + * + * @return void + */ + public function testAnOrderPlacedOutsideThisProtocolIsNotDisclosed(): void + { + try { + $this->handler(false)->getOrder(self::CHECKOUT_ID); + } catch (UcpException $exception) { + $this->assertSame(404, $exception->getStatusCode()); + $this->assertSame('not_found', $exception->getErrorCode()); + + return; + } + + $this->fail('an unlinked order was served'); + } + + /** + * @return void + */ + public function testTheResponseIsBuiltForTheLinkedCheckout(): void + { + $converter = $this->createMock(OrderToOrderResponse::class); + $converter->expects($this->once()) + ->method('convert') + ->with($this->isInstanceOf(Order::class), self::CHECKOUT_ID) + ->willReturn($this->createMock(OrderResponseInterface::class)); + + $this->handler(true, converter: $converter)->getOrder(self::CHECKOUT_ID); + } + + /** + * The shipment log says what the store did, so an agent cannot write into it — but it is told so, + * rather than left to assume its event landed. + * + * @return void + */ + public function testASubmittedShipmentEventIsRefusedInWriting(): void + { + $recorded = $this->createMock(OrderResponseInterface::class); + $recorded->method('getFulfillment')->willReturn($this->fulfillment(0)); + $recorded->expects($this->once()) + ->method('setMessages') + ->with($this->callback(static function (array $messages): bool { + return count($messages) === 1 + && $messages[0]->getType() === 'warning' + && $messages[0]->getPath() === '$.fulfillment.events'; + })); + + $this->updateWith($recorded, submittedEvents: 1); + } + + /** + * @return void + */ + public function testAnUpdateThatAddsNoShipmentEventIsNotWarnedAbout(): void + { + $recorded = $this->createMock(OrderResponseInterface::class); + $recorded->method('getFulfillment')->willReturn($this->fulfillment(0)); + $recorded->expects($this->never())->method('setMessages'); + + $this->updateWith($recorded, submittedEvents: 0); + } + + /** + * @param OrderResponseInterface $recorded Order the converter reports + * @param int $submittedEvents How many shipment events the request carries + * @return void + */ + private function updateWith(OrderResponseInterface $recorded, int $submittedEvents): void + { + $converter = $this->createMock(OrderToOrderResponse::class); + $converter->method('convert')->willReturn($recorded); + + $requestFulfillment = $this->createMock(OrderUpdateRequestFulfillmentInterface::class); + $requestFulfillment->method('getEvents')->willReturn($this->events($submittedEvents)); + + $request = $this->createMock(OrderUpdateRequestInterface::class); + $request->method('getFulfillment')->willReturn($requestFulfillment); + + $this->handler(true, converter: $converter)->updateOrder(self::CHECKOUT_ID, $request); + } + + /** + * @param int $count + * @return OrderResponseFulfillmentInterface + */ + private function fulfillment(int $count): OrderResponseFulfillmentInterface + { + $fulfillment = $this->createMock(OrderResponseFulfillmentInterface::class); + $fulfillment->method('getEvents')->willReturn($this->events($count)); + + return $fulfillment; + } + + /** + * @param int $count + * @return FulfillmentEventInterface[] + */ + private function events(int $count): array + { + $events = []; + + for ($index = 0; $index < $count; $index++) { + $events[] = $this->createMock(FulfillmentEventInterface::class); + } + + return $events; + } + + /** + * @return void + */ + public function testAnUpdateRecordsTheAdjustmentsItCarries(): void + { + $adjustment = $this->createMock(AdjustmentInterface::class); + $recorder = $this->createMock(AdjustmentRecorder::class); + $recorder->expects($this->once()) + ->method('record') + ->with($this->isInstanceOf(Order::class), [$adjustment]); + + $request = $this->createMock(OrderUpdateRequestInterface::class); + $request->method('getAdjustments')->willReturn([$adjustment]); + + $this->handler(true, recorder: $recorder) + ->updateOrder(self::CHECKOUT_ID, $request); + } + + /** + * @return void + */ + public function testAnUpdateToAnOrderFromAnotherProtocolIsRefused(): void + { + $recorder = $this->createMock(AdjustmentRecorder::class); + $recorder->expects($this->never())->method('record'); + + $this->expectException(UcpException::class); + + $this->handler(false, recorder: $recorder) + ->updateOrder(self::CHECKOUT_ID, $this->createMock(OrderUpdateRequestInterface::class)); + } + + /** + * @param bool $linked Whether the checkout session placed an order this protocol may hand out + * @param OrderToOrderResponse|null $converter + * @param AdjustmentRecorder|null $recorder + * @return OrderHandler + */ + private function handler( + bool $linked, + ?OrderToOrderResponse $converter = null, + ?AdjustmentRecorder $recorder = null + ): OrderHandler { + $order = $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEntityId']) + ->getMock(); + $order->method('getEntityId')->willReturn(self::ENTITY_ID); + + $lookup = $this->createMock(SessionOrderLookup::class); + // Only the session that placed the order finds it; every other identifier finds nothing. + $lookup->method('find')->willReturnCallback( + static fn (string $identifier): ?Order => + $linked && $identifier === self::CHECKOUT_ID ? $order : null + ); + + if ($converter === null) { + $converter = $this->createMock(OrderToOrderResponse::class); + $converter->method('convert')->willReturn($this->createMock(OrderResponseInterface::class)); + } + + $messageFactory = $this->createMock(MessageInterfaceFactory::class); + $messageFactory->method('create')->willReturnCallback( + static fn (array $arguments = []): Message => new Message($arguments['data'] ?? []) + ); + + return new OrderHandler( + $lookup, + $converter, + $recorder ?? $this->createMock(AdjustmentRecorder::class), + $messageFactory + ); + } +} diff --git a/Test/Unit/Model/Service/Shopping/ProfileUrlValidatorTest.php b/Test/Unit/Model/Service/Shopping/ProfileUrlValidatorTest.php index 0fd1ec4..4df4632 100644 --- a/Test/Unit/Model/Service/Shopping/ProfileUrlValidatorTest.php +++ b/Test/Unit/Model/Service/Shopping/ProfileUrlValidatorTest.php @@ -14,11 +14,15 @@ use InvalidArgumentException; use Magebit\UniversalCommerce\Model\Service\Shopping\ProfileUrlValidator; +use Magebit\UniversalCommerce\Model\Service\Shopping\TrustedProfileOrigins; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class ProfileUrlValidatorTest extends TestCase { /** @var ProfileUrlValidator */ + private TrustedProfileOrigins&MockObject $trustedOrigins; + private ProfileUrlValidator $validator; /** @@ -26,7 +30,8 @@ class ProfileUrlValidatorTest extends TestCase */ protected function setUp(): void { - $this->validator = new ProfileUrlValidator(); + $this->trustedOrigins = $this->createMock(TrustedProfileOrigins::class); + $this->validator = new ProfileUrlValidator($this->trustedOrigins); } /** @@ -102,4 +107,35 @@ public function testAcceptsPublicIpv6Literal(): void $this->validator->assertFetchable('https://[2606:2800:220:1:248:1893:25c8:1946]/p.json') ); } + + /** + * An origin the deployment named is reachable even where the guard would refuse it — that is what + * the setting is for. It still has to resolve. + * + * @return void + */ + public function testAnOriginTheDeploymentTrustsIsAccepted(): void + { + $this->trustedOrigins->method('trusts')->willReturn(true); + + $this->assertSame( + ['127.0.0.1'], + $this->validator->assertFetchable('http://127.0.0.1:8285/profile.json') + ); + } + + /** + * Credentials in the URL stay refused whether the origin is trusted or not: a profile fetch has no + * business carrying them. + * + * @return void + */ + public function testATrustedOriginStillMayNotCarryCredentials(): void + { + $this->trustedOrigins->method('trusts')->willReturn(true); + + $this->expectException(InvalidArgumentException::class); + + $this->validator->assertFetchable('http://user:pass@127.0.0.1:8285/profile.json'); + } } diff --git a/Test/Unit/Model/Service/Shopping/TrustedProfileOriginsTest.php b/Test/Unit/Model/Service/Shopping/TrustedProfileOriginsTest.php new file mode 100644 index 0000000..647e9b3 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/TrustedProfileOriginsTest.php @@ -0,0 +1,66 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping; + +use Magebit\UniversalCommerce\Model\Service\Shopping\TrustedProfileOrigins; +use Magento\Framework\App\DeploymentConfig; +use PHPUnit\Framework\TestCase; + +class TrustedProfileOriginsTest extends TestCase +{ + /** + * @param mixed $configured + * @param string $url + * @param bool $expected + * @return void + * @dataProvider originProvider + */ + public function testAnOriginIsTrustedOnlyWhenTheDeploymentNamedIt( + mixed $configured, + string $url, + bool $expected + ): void { + $deploymentConfig = $this->createMock(DeploymentConfig::class); + $deploymentConfig->method('get') + ->with(TrustedProfileOrigins::CONFIG_PATH) + ->willReturn($configured); + + $this->assertSame($expected, (new TrustedProfileOrigins($deploymentConfig))->trusts($url)); + } + + /** + * @return array + */ + public static function originProvider(): array + { + $localhost = ['http://localhost:8285']; + + return [ + 'nothing configured' => [null, 'http://localhost:8285/p.json', false], + 'not a list' => ['http://localhost:8285', 'http://localhost:8285/p.json', false], + 'exact match' => [$localhost, 'http://localhost:8285/p.json', true], + 'trailing slash configured' => [ + ['http://localhost:8285/'], + 'http://localhost:8285/p.json', + true, + ], + 'different case' => [$localhost, 'HTTP://LOCALHOST:8285/p.json', true], + 'another port on the same host' => [$localhost, 'http://localhost:9000/p.json', false], + 'another scheme' => [$localhost, 'https://localhost:8285/p.json', false], + 'another host' => [$localhost, 'http://evil.test:8285/p.json', false], + 'no port when one was named' => [$localhost, 'http://localhost/p.json', false], + 'not a url' => [$localhost, 'not-a-url', false], + 'blank entries ignored' => [['', ' '], 'http://localhost:8285/p.json', false], + ]; + } +} diff --git a/Test/Unit/Model/Service/Shopping/Validation/QuoteFulfillmentValidatorTest.php b/Test/Unit/Model/Service/Shopping/Validation/QuoteFulfillmentValidatorTest.php new file mode 100644 index 0000000..75b5173 --- /dev/null +++ b/Test/Unit/Model/Service/Shopping/Validation/QuoteFulfillmentValidatorTest.php @@ -0,0 +1,150 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Service\Shopping\Validation; + +use Magebit\AgenticCore\Model\Fulfillment\ShippingOption; +use Magebit\AgenticCore\Model\Fulfillment\ShippingOptionResolver; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterface; +use Magebit\UcpSpec\Api\Shopping\Types\MessageInterfaceFactory; +use Magebit\UniversalCommerce\Model\Service\Shopping\Validation\QuoteFulfillmentValidator; +use Magento\Quote\Model\Quote; +use Magento\Quote\Model\Quote\Address; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +class QuoteFulfillmentValidatorTest extends TestCase +{ + /** + * @var array> + */ + private array $createdMessages = []; + + private ShippingOptionResolver&MockObject $shippingOptionResolver; + + private QuoteFulfillmentValidator $validator; + + /** + * @return void + */ + protected function setUp(): void + { + $this->createdMessages = []; + + $messageFactory = $this->createMock(MessageInterfaceFactory::class); + $messageFactory->method('create')->willReturnCallback(function (array $arguments) { + $this->createdMessages[] = $arguments['data']; + return $this->createMock(MessageInterface::class); + }); + + $this->shippingOptionResolver = $this->createMock(ShippingOptionResolver::class); + $this->validator = new QuoteFulfillmentValidator($messageFactory, $this->shippingOptionResolver); + } + + /** + * @return void + */ + public function testAVirtualQuoteNeedsNoFulfillment(): void + { + $errors = $this->validator->validate($this->quote(true, '')); + + $this->assertSame([], $errors); + $this->assertSame([], $this->createdMessages); + } + + /** + * @return void + */ + public function testAMissingSelectionIsReportedAgainstTheFulfillmentTree(): void + { + $this->shippingOptionResolver->expects($this->never())->method('resolve'); + + $errors = $this->validator->validate($this->quote(false, '')); + + $this->assertCount(1, $errors); + $this->assertSame('missing', $this->createdMessages[0]['code']); + $this->assertSame('$.fulfillment', $this->createdMessages[0]['path']); + $this->assertStringContainsString('fulfillment', $this->createdMessages[0]['content']); + } + + /** + * @return void + */ + public function testAnOfferedSelectionPasses(): void + { + $this->shippingOptionResolver->method('resolve')->willReturn([$this->option('flatrate_flatrate')]); + + $this->assertSame([], $this->validator->validate($this->quote(false, 'flatrate_flatrate'))); + } + + /** + * @return void + */ + public function testASelectionTheStoreDoesNotOfferIsReported(): void + { + $this->shippingOptionResolver->method('resolve')->willReturn([$this->option('flatrate_flatrate')]); + + $errors = $this->validator->validate($this->quote(false, 'std-ship')); + + $this->assertCount(1, $errors); + $this->assertSame('invalid', $this->createdMessages[0]['code']); + $this->assertSame( + '$.fulfillment.methods[0].groups[0].selected_option_id', + $this->createdMessages[0]['path'] + ); + $this->assertStringContainsString('std-ship', $this->createdMessages[0]['content']); + } + + /** + * With no rates at all the destination is what needs fixing, and the address validator says so. + * + * @return void + */ + public function testWithNoOptionsAtAllTheSelectionIsNotSecondGuessed(): void + { + $this->shippingOptionResolver->method('resolve')->willReturn([]); + + $this->assertSame([], $this->validator->validate($this->quote(false, 'std-ship'))); + } + + /** + * @param string $id + * @return ShippingOption + */ + private function option(string $id): ShippingOption + { + return new ShippingOption($id, 'Fixed', null, 'Flat Rate', 500, 0, 500); + } + + /** + * @param bool $isVirtual + * @param string $shippingMethod + * @return Quote&MockObject + */ + private function quote(bool $isVirtual, string $shippingMethod): Quote&MockObject + { + $address = $this->getMockBuilder(Address::class) + ->disableOriginalConstructor() + ->onlyMethods(['getShippingMethod']) + ->getMock(); + $address->method('getShippingMethod')->willReturn($shippingMethod); + + $quote = $this->getMockBuilder(Quote::class) + ->disableOriginalConstructor() + ->onlyMethods(['getIsVirtual', 'getShippingAddress']) + ->getMock(); + $quote->method('getIsVirtual')->willReturn($isVirtual); + $quote->method('getShippingAddress')->willReturn($address); + + return $quote; + } +} diff --git a/Test/Unit/Model/Simulation/SecretTest.php b/Test/Unit/Model/Simulation/SecretTest.php new file mode 100644 index 0000000..49dd969 --- /dev/null +++ b/Test/Unit/Model/Simulation/SecretTest.php @@ -0,0 +1,106 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Simulation; + +use Magebit\UniversalCommerce\Model\Simulation\Secret; +use Magento\Framework\App\DeploymentConfig; +use PHPUnit\Framework\TestCase; + +class SecretTest extends TestCase +{ + private const SECRET = 'super-secret-sim-key'; + + /** + * @return void + */ + public function testASecretInEnvSwitchesTheEndpointOn(): void + { + $this->assertTrue($this->secret(self::SECRET)->isConfigured()); + } + + /** + * A store that never opted in must not expose the endpoint at all. + * + * @return void + */ + public function testNoSecretLeavesTheEndpointOff(): void + { + $this->assertFalse($this->secret(null)->isConfigured()); + } + + /** + * @return void + */ + public function testWhitespaceIsNotASecret(): void + { + $this->assertFalse($this->secret(' ')->isConfigured()); + } + + /** + * @return void + */ + public function testANonStringValueIsIgnored(): void + { + $this->assertFalse($this->secret(['not-a-secret'])->isConfigured()); + } + + /** + * @return void + */ + public function testTheMatchingSecretIsAccepted(): void + { + $this->assertTrue($this->secret(self::SECRET)->matches(self::SECRET)); + } + + /** + * @return void + */ + public function testAWrongSecretIsRejected(): void + { + $this->assertFalse($this->secret(self::SECRET)->matches('for-sure-incorrect-secret')); + } + + /** + * @return void + */ + public function testAMissingSecretIsRejected(): void + { + $this->assertFalse($this->secret(self::SECRET)->matches(null)); + $this->assertFalse($this->secret(self::SECRET)->matches('')); + } + + /** + * With nothing configured, nothing the caller sends can be a match — an empty header included. + * + * @return void + */ + public function testNothingMatchesWhenNoSecretIsConfigured(): void + { + $this->assertFalse($this->secret(null)->matches('')); + $this->assertFalse($this->secret(null)->matches('anything')); + } + + /** + * @param mixed $configured Value env.php holds + * @return Secret + */ + private function secret(mixed $configured): Secret + { + $deploymentConfig = $this->createMock(DeploymentConfig::class); + $deploymentConfig->method('get') + ->with(Secret::CONFIG_PATH) + ->willReturn($configured); + + return new Secret($deploymentConfig); + } +} diff --git a/Test/Unit/Model/Validation/RequestValidatorTest.php b/Test/Unit/Model/Validation/RequestValidatorTest.php new file mode 100644 index 0000000..1b7e242 --- /dev/null +++ b/Test/Unit/Model/Validation/RequestValidatorTest.php @@ -0,0 +1,161 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Validation; + +use Magebit\UcpSpec\Api\Shopping\OrderUpdateRequestInterface; +use Magebit\UcpSpec\Api\Shopping\Types\AdjustmentInterface; +use Magebit\AgenticCore\Model\Validation\ConstraintChecker; +use Magebit\AgenticCore\Model\Validation\RequestValidator; +use PHPUnit\Framework\TestCase; + +/** + * The shared validator has its own tests against stub interfaces. This one proves the real generated + * interfaces carry what it reads, so a change in the specification library shows up here. + */ +class RequestValidatorTest extends TestCase +{ + private RequestValidator $validator; + + /** + * @return void + */ + protected function setUp(): void + { + $this->validator = new RequestValidator(new ConstraintChecker()); + } + + /** + * @return void + */ + public function testAValueTheSpecificationAllowsPasses(): void + { + $result = $this->validator->validate($this->adjustment('pending'), AdjustmentInterface::class); + + $this->assertTrue($result->isValid()); + } + + /** + * The generated interfaces name every allowed value, so a value outside the list is caught without + * anything here restating the specification. + * + * @return void + */ + public function testAValueOutsideTheSpecificationsListIsRejected(): void + { + $result = $this->validator->validate($this->adjustment('INVALID_STATUS'), AdjustmentInterface::class); + + $this->assertFalse($result->isValid()); + $this->assertArrayHasKey('status', $result->getErrors()); + $this->assertStringContainsString('pending', $result->getErrors()['status']); + } + + /** + * `adjustment.type` is an open string in the specification, which is why the interface names no + * values for it. A field like that must not be second-guessed. + * + * @return void + */ + public function testAFieldTheSpecificationLeavesOpenAcceptsAnything(): void + { + $data = $this->adjustment('pending'); + $data['type'] = 'something-a-merchant-invented'; + + $this->assertTrue($this->validator->validate($data, AdjustmentInterface::class)->isValid()); + } + + /** + * @return void + */ + public function testAMissingRequiredFieldIsReported(): void + { + $data = $this->adjustment('pending'); + unset($data['occurred_at']); + + $result = $this->validator->validate($data, AdjustmentInterface::class); + + $this->assertFalse($result->isValid()); + $this->assertArrayHasKey('occurred_at', $result->getErrors()); + } + + /** + * @return void + */ + public function testAListSentAsSomethingElseIsReported(): void + { + $data = $this->adjustment('pending'); + $data['totals'] = 'not-a-list'; + + $result = $this->validator->validate($data, AdjustmentInterface::class); + + $this->assertFalse($result->isValid()); + $this->assertArrayHasKey('totals', $result->getErrors()); + } + + /** + * A refund-only update still has to describe the whole order, fulfillment included, so the handler + * behind it never sees a body without one. + * + * @return void + */ + public function testAnOrderUpdateWithoutFulfillmentIsRejected(): void + { + $data = $this->orderUpdate(); + unset($data['fulfillment']); + + $result = $this->validator->validate($data, OrderUpdateRequestInterface::class); + + $this->assertFalse($result->isValid()); + $this->assertArrayHasKey('fulfillment', $result->getErrors()); + } + + /** + * @return void + */ + public function testAnOrderUpdateCarryingFulfillmentPasses(): void + { + $result = $this->validator->validate($this->orderUpdate(), OrderUpdateRequestInterface::class); + + $this->assertTrue($result->isValid()); + } + + /** + * @return array + */ + private function orderUpdate(): array + { + return [ + 'ucp' => ['version' => '2026-01-23'], + 'id' => 'checkout_1', + 'checkout_id' => 'checkout_1', + 'permalink_url' => 'https://example.com/orders/checkout_1', + 'line_items' => [], + 'fulfillment' => ['events' => []], + 'totals' => [], + 'adjustments' => [$this->adjustment('pending')], + ]; + } + + /** + * @param string $status + * @return array + */ + private function adjustment(string $status): array + { + return [ + 'id' => 'adj_1', + 'type' => 'refund', + 'occurred_at' => '2026-08-31T12:00:00Z', + 'status' => $status, + ]; + } +} diff --git a/Test/Unit/Model/Webhook/DeliveryHeadersProviderTest.php b/Test/Unit/Model/Webhook/DeliveryHeadersProviderTest.php new file mode 100644 index 0000000..3291e1e --- /dev/null +++ b/Test/Unit/Model/Webhook/DeliveryHeadersProviderTest.php @@ -0,0 +1,268 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Webhook; + +use Magebit\AgenticCore\Api\Data\SigningKeyInterface; +use Magebit\AgenticCore\Api\Data\WebhookDeliveryInterface; +use Magebit\AgenticCore\Api\SigningKeyRepositoryInterface; +use Magebit\AgenticCore\Model\Signature\EcdsaP256Signer; +use Magebit\AgenticCore\Model\Signature\MessageSigner; +use Magebit\AgenticCore\Model\Signature\SignatureBase; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\Webhook\DeliveryHeadersProvider; +use PHPUnit\Framework\TestCase; + +class DeliveryHeadersProviderTest extends TestCase +{ + private const PAYLOAD = '{"id":"order_1"}'; + private const TARGET_URL = 'https://platform.example/webhooks/ucp/orders'; + private const KID = 'merchant-2026'; + + /** + * When the event happened, 1767225600. The attempt below is deliberately later. + */ + private const OCCURRED_AT = '2026-01-01 00:00:00'; + private const OCCURRED_TIMESTAMP = 1767225600; + private const ATTEMPT_TIMESTAMP = 1767229200; + + /** + * One pair for the whole class; a fresh pair per test would prove nothing extra. + * + * @var string + */ + private static string $privateKey = ''; + + /** + * @var string + */ + private static string $publicKey = ''; + + /** + * @return void + */ + public static function setUpBeforeClass(): void + { + $resource = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + openssl_pkey_export($resource, $pem); + self::$privateKey = (string) $pem; + self::$publicKey = (string) openssl_pkey_get_details($resource)['key']; + } + + /** + * Fixed against an independently computed digest rather than recomputed the way the code does: + * php -r 'echo base64_encode(hash("sha256", "{\"id\":\"order_1\"}", true));' + * + * @return void + */ + public function testTheBodyDigestFollowsRfc9530(): void + { + $expected = base64_encode(hash('sha256', self::PAYLOAD, true)); + + $this->assertSame('sha-256=:' . $expected . ':', $this->headers()['Content-Digest']); + } + + /** + * A retry is the same event, so the occurrence time must not move to the attempt's. Reporting the + * attempt instead would tell the receiver the order changed again every time delivery was retried. + * + * @return void + */ + public function testTheTimestampIsWhenTheEventHappenedNotWhenItIsSent(): void + { + $this->assertSame((string) self::OCCURRED_TIMESTAMP, $this->headers()['Webhook-Timestamp']); + $this->assertNotSame((string) self::ATTEMPT_TIMESTAMP, $this->headers()['Webhook-Timestamp']); + } + + /** + * @return void + */ + public function testTheEventIdIsTheDeliverysOwnReference(): void + { + $this->assertSame('11111111-2222-4333-8444-555555555555', $this->headers()['Webhook-Id']); + } + + /** + * The spec requires the sender's profile URI in RFC 8941 dictionary syntax, pointing at discovery. + * + * @return void + */ + public function testTheSenderIsIdentifiedByItsOwnProfileUri(): void + { + $this->assertSame('profile="https://merchant.test/.well-known/ucp"', $this->headers()['UCP-Agent']); + } + + /** + * The spec says webhooks MUST be signed, so an unsigned delivery is not a configuration choice. + * + * @return void + */ + public function testTheDeliveryIsSigned(): void + { + $headers = $this->headers(); + + $this->assertArrayHasKey('Signature', $headers); + $this->assertArrayHasKey('Signature-Input', $headers); + } + + /** + * An API key authenticates a platform calling us, not us calling a platform. + * + * @return void + */ + public function testNoApiKeyIsSent(): void + { + $this->assertArrayNotHasKey('X-API-Key', $this->headers()); + } + + /** + * @return void + */ + public function testTheSignatureNamesTheKeyPublishedInOurProfile(): void + { + $this->assertStringContainsString(';keyid="' . self::KID . '"', $this->headers()['Signature-Input']); + } + + /** + * @return void + */ + public function testTheSignatureCoversTheTargetTheIdentityAndTheBody(): void + { + $this->assertStringStartsWith( + 'sig1=("@method" "@authority" "@path" "ucp-agent" "content-digest" "content-type")', + $this->headers()['Signature-Input'] + ); + } + + /** + * The authority is the receiver's host, not ours. Signing our own would let a recipient relay the + * delivery to a different endpoint with the signature still intact. + * + * @return void + */ + public function testTheSignedAuthorityIsTheReceiversHost(): void + { + $components = $this->expectedComponents(); + + $this->assertSame('platform.example', $components['@authority']); + $this->assertSame('/webhooks/ucp/orders', $components['@path']); + } + + /** + * The bytes actually signed have to be the ones a verifier rebuilds from the headers, which only a + * real verification against the public key proves. + * + * @return void + */ + public function testTheSignatureVerifiesAgainstThePublishedKey(): void + { + $this->assertSame(1, $this->verify($this->expectedComponents())); + } + + /** + * @return void + */ + public function testATamperedBodyBreaksTheSignature(): void + { + $components = $this->expectedComponents(); + $components['content-digest'] = 'sha-256=:' . base64_encode(hash('sha256', 'tampered', true)) . ':'; + + $this->assertSame(0, $this->verify($components)); + } + + /** + * @param array $components + * @return int + */ + private function verify(array $components): int + { + $signature = $this->headers()[MessageSigner::HEADER]; + $raw = (string) base64_decode(substr($signature, strlen('sig1=:'), -1), true); + $signer = new MessageSigner(new SignatureBase(), new EcdsaP256Signer()); + + return openssl_verify( + $signer->baseFor($components, self::KID), + $this->derFrom($raw), + self::$publicKey, + OPENSSL_ALGO_SHA256 + ); + } + + /** + * @return array + */ + private function expectedComponents(): array + { + return [ + '@method' => 'POST', + '@authority' => 'platform.example', + '@path' => '/webhooks/ucp/orders', + 'ucp-agent' => 'profile="https://merchant.test/.well-known/ucp"', + 'content-digest' => 'sha-256=:' . base64_encode(hash('sha256', self::PAYLOAD, true)) . ':', + 'content-type' => 'application/json', + ]; + } + + /** + * @return array + */ + private function headers(): array + { + $config = $this->createMock(Config::class); + $config->method('getApiBaseUrl')->willReturn('https://merchant.test'); + + $delivery = $this->createMock(WebhookDeliveryInterface::class); + $delivery->method('getPayload')->willReturn(self::PAYLOAD); + $delivery->method('getReference')->willReturn('11111111-2222-4333-8444-555555555555'); + $delivery->method('getCreatedAt')->willReturn(self::OCCURRED_AT); + $delivery->method('getUrl')->willReturn(self::TARGET_URL); + + $key = $this->createMock(SigningKeyInterface::class); + $key->method('getKid')->willReturn(self::KID); + $key->method('getPrivateKeyPem')->willReturn(self::$privateKey); + + $keys = $this->createMock(SigningKeyRepositoryInterface::class); + $keys->method('getSigningKey')->willReturn($key); + + return (new DeliveryHeadersProvider( + $config, + $keys, + new MessageSigner(new SignatureBase(), new EcdsaP256Signer()) + ))->getHeaders($delivery, self::ATTEMPT_TIMESTAMP); + } + + /** + * @param string $raw + * @return string + */ + private function derFrom(string $raw): string + { + $sequence = $this->derInteger(substr($raw, 0, 32)) . $this->derInteger(substr($raw, 32, 32)); + + return "\x30" . chr(strlen($sequence)) . $sequence; + } + + /** + * @param string $value + * @return string + */ + private function derInteger(string $value): string + { + $value = ltrim($value, "\x00"); + + if ($value === '' || (ord($value[0]) & 0x80) !== 0) { + $value = "\x00" . $value; + } + + return "\x02" . chr(strlen($value)) . $value; + } +} diff --git a/Test/Unit/Model/Webhook/OrderEventNotifierTest.php b/Test/Unit/Model/Webhook/OrderEventNotifierTest.php new file mode 100644 index 0000000..6f26bc5 --- /dev/null +++ b/Test/Unit/Model/Webhook/OrderEventNotifierTest.php @@ -0,0 +1,213 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit\Model\Webhook; + +use Magebit\AgenticCore\Api\OrderLinkRepositoryInterface; +use Magebit\AgenticCore\Model\Webhook\Dispatcher; +use Magebit\UcpSpec\Api\Shopping\OrderResponseInterface; +use Magebit\UniversalCommerce\Api\CheckoutMetaRepositoryInterface; +use Magebit\UniversalCommerce\Api\Data\CheckoutMetaInterface; +use Magebit\UniversalCommerce\Model\Config; +use Magebit\UniversalCommerce\Model\IdempotencyHandler; +use Magebit\UniversalCommerce\Model\Service\Shopping\Converter\OrderToOrderResponse; +use Magebit\UniversalCommerce\Model\Webhook\OrderEventNotifier; +use Magento\Framework\DataObject\IdentityGeneratorInterface; +use Magento\Framework\Exception\CouldNotSaveException; +use Magento\Framework\Exception\NoSuchEntityException; +use Magento\Sales\Model\Order; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +class OrderEventNotifierTest extends TestCase +{ + private const ENTITY_ID = 42; + private const CHECKOUT_ID = 'checkout_session_placeholder_0001'; + private const AGENT_URL = 'https://agent.test/hook'; + + /** @var Dispatcher&MockObject */ + private Dispatcher $dispatcher; + + /** + * @return void + */ + protected function setUp(): void + { + $this->dispatcher = $this->createMock(Dispatcher::class); + } + + /** + * @return void + */ + public function testTheOrderIsQueuedToTheAgentsOwnUrl(): void + { + $this->dispatcher->expects($this->once()) + ->method('enqueue') + ->with(IdempotencyHandler::SCOPE, self::AGENT_URL, $this->isType('string'), $this->isType('string')); + + $this->notifier()->notify($this->order()); + } + + /** + * Delivery ships disabled because no credential scheme has been chosen, and a disabled merchant must + * not accumulate rows nobody will send. + * + * @return void + */ + public function testNothingIsQueuedWhileDeliveryIsDisabled(): void + { + $this->dispatcher->expects($this->never())->method('enqueue'); + + $this->notifier(enabled: false)->notify($this->order()); + } + + /** + * @return void + */ + public function testAnOrderFromNoSessionOfThisProtocolIsIgnored(): void + { + $this->dispatcher->expects($this->never())->method('enqueue'); + + $this->notifier(checkoutId: null)->notify($this->order()); + } + + /** + * The URL comes from the agent's own profile, and most profiles declare none. + * + * @return void + */ + public function testAnAgentThatDeclaredNoUrlIsNotCalled(): void + { + $this->dispatcher->expects($this->never())->method('enqueue'); + + $this->notifier(url: null)->notify($this->order()); + } + + /** + * @return void + */ + public function testAMissingCheckoutRecordIsNotAnError(): void + { + $this->dispatcher->expects($this->never())->method('enqueue'); + + $this->notifier(metaMissing: true)->notify($this->order()); + } + + /** + * Each event gets its own identifier: it becomes Webhook-Id, which names the event rather than the + * order or the session, and has to stay put across retries of that one event. + * + * @return void + */ + public function testEachEventCarriesItsOwnIdentifier(): void + { + $references = []; + + $this->dispatcher->method('enqueue')->willReturnCallback( + function (string $scope, string $url, string $payload, string $reference) use (&$references): void { + $references[] = $reference; + } + ); + + $notifier = $this->notifier(); + $notifier->notify($this->order()); + $notifier->notify($this->order()); + + $this->assertCount(2, $references); + $this->assertNotSame($references[0], $references[1]); + } + + /** + * The order has already happened by the time this runs, so a queue failure is logged rather than + * thrown back into the transaction that placed it. + * + * @return void + */ + public function testAQueueFailureDoesNotEscape(): void + { + $this->dispatcher->method('enqueue')->willThrowException(new CouldNotSaveException(__('nope'))); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('critical'); + + $this->notifier(logger: $logger)->notify($this->order()); + } + + /** + * @param bool $enabled + * @param string|null $checkoutId + * @param string|null $url + * @param bool $metaMissing + * @param LoggerInterface|null $logger + * @return OrderEventNotifier + */ + private function notifier( + bool $enabled = true, + ?string $checkoutId = self::CHECKOUT_ID, + ?string $url = self::AGENT_URL, + bool $metaMissing = false, + ?LoggerInterface $logger = null + ): OrderEventNotifier { + $links = $this->createMock(OrderLinkRepositoryInterface::class); + $links->method('findSessionId')->willReturn($checkoutId); + + $meta = $this->createMock(CheckoutMetaInterface::class); + $meta->method('getWebhookUrl')->willReturn($url); + + $metaRepository = $this->createMock(CheckoutMetaRepositoryInterface::class); + + if ($metaMissing) { + $metaRepository->method('getByCheckoutId')->willThrowException(new NoSuchEntityException(__('gone'))); + } else { + $metaRepository->method('getByCheckoutId')->willReturn($meta); + } + + $converter = $this->createMock(OrderToOrderResponse::class); + $converter->method('convert')->willReturn($this->createMock(OrderResponseInterface::class)); + + $identity = $this->createMock(IdentityGeneratorInterface::class); + $sequence = 0; + $identity->method('generateId')->willReturnCallback(function () use (&$sequence): string { + return sprintf('11111111-2222-4333-8444-%012d', ++$sequence); + }); + + $config = $this->createMock(Config::class); + $config->method('areWebhooksEnabled')->willReturn($enabled); + + return new OrderEventNotifier( + $this->dispatcher, + $links, + $metaRepository, + $converter, + $identity, + $config, + $logger ?? $this->createMock(LoggerInterface::class) + ); + } + + /** + * @return Order + */ + private function order(): Order + { + $order = $this->getMockBuilder(Order::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEntityId', 'getStoreId', 'getIncrementId']) + ->getMock(); + $order->method('getEntityId')->willReturn(self::ENTITY_ID); + $order->method('getStoreId')->willReturn(1); + $order->method('getIncrementId')->willReturn('000000123'); + + return $order; + } +} diff --git a/Test/Unit/SchemaAssertTest.php b/Test/Unit/SchemaAssertTest.php index 51934d0..a89c7d8 100644 --- a/Test/Unit/SchemaAssertTest.php +++ b/Test/Unit/SchemaAssertTest.php @@ -25,6 +25,12 @@ class SchemaAssertTest extends TestCase private const TOTAL_SCHEMA = 'shopping/types/total_resp.json'; + /** + * `total.type` became an open string in UCP `2026-04-08`, so the enum assertion needs a schema + * that still closes its vocabulary. + */ + private const FULFILLMENT_METHOD_SCHEMA = 'shopping/types/fulfillment_available_method_resp.json'; + public function testAcceptsPayloadMatchingSchema(): void { $this->assertMatchesSchema( @@ -36,7 +42,10 @@ public function testAcceptsPayloadMatchingSchema(): void public function testRejectsPayloadViolatingEnumAndReportsPointers(): void { try { - $this->assertMatchesSchema(['type' => 'not_a_total_type', 'amount' => 100], self::TOTAL_SCHEMA); + $this->assertMatchesSchema( + ['type' => 'teleport', 'line_item_ids' => ['li_1']], + self::FULFILLMENT_METHOD_SCHEMA + ); } catch (AssertionFailedError $error) { $message = $error->getMessage(); $this->assertStringContainsString('[enum]', $message); diff --git a/Test/Unit/ShoppingSchemaConformanceTest.php b/Test/Unit/ShoppingSchemaConformanceTest.php index 69e317d..5c0333c 100644 --- a/Test/Unit/ShoppingSchemaConformanceTest.php +++ b/Test/Unit/ShoppingSchemaConformanceTest.php @@ -23,6 +23,22 @@ class ShoppingSchemaConformanceTest extends TestCase use SchemaAssert; private const CHECKOUT_SCHEMA = 'shopping/checkout_resp.json'; + private const ERROR_SCHEMA = 'shopping/types/error_response.json'; + private const CART_SCHEMA = 'shopping/cart_resp.json'; + private const SEARCH_SCHEMA = 'shopping/catalog_search.json#/$defs/search_response'; + private const LOOKUP_SCHEMA = 'shopping/catalog_lookup.json#/$defs/lookup_response'; + private const PRODUCT_SCHEMA = 'shopping/catalog_lookup.json#/$defs/get_product_response'; + private const ORDER_SCHEMA = 'shopping/order_resp.json'; + private const ORDER_FIXTURE = 'shopping.order.get.200.json'; + private const SHIPPED_ORDER_FIXTURE = 'shopping.order.get.shipped_refunded.200.json'; + + /** + * Response fields the spec puts in an extension, mapped to the capability that declares them. + */ + private const EXTENSION_FIELDS = [ + 'fulfillment' => 'dev.ucp.shopping.fulfillment', + 'discounts' => 'dev.ucp.shopping.discount', + ]; /** * @return array @@ -69,6 +85,350 @@ public function testCompleteResponseMatchesSpec(): void ); } + /** + * @return array + */ + public static function orderFixtureProvider(): array + { + return [ + 'placed 200' => [self::ORDER_FIXTURE], + 'shipped and partly refunded 200' => [self::SHIPPED_ORDER_FIXTURE], + ]; + } + + /** + * @dataProvider orderFixtureProvider + * @param string $fixture + * @return void + */ + public function testOrderResponseMatchesSpec(string $fixture): void + { + $this->assertMatchesSchema(self::loadFixtureObject($fixture), self::ORDER_SCHEMA); + } + + /** + * Every id an order references has to name a line item the same order lists, or an agent reading the + * event log cannot tell which item shipped. + * + * @dataProvider orderFixtureProvider + * @param string $fixture + * @return void + */ + public function testEveryReferencedLineItemExists(string $fixture): void + { + $order = self::loadFixture($fixture); + $known = array_column($order['line_items'], 'id'); + + foreach ($order['fulfillment']['expectations'] ?? [] as $expectation) { + $this->assertSame([], array_diff(array_column($expectation['line_items'], 'id'), $known)); + } + + foreach ($order['fulfillment']['events'] ?? [] as $event) { + $this->assertSame([], array_diff(array_column($event['line_items'], 'id'), $known)); + } + + foreach ($order['adjustments'] ?? [] as $adjustment) { + $this->assertSame([], array_diff(array_column($adjustment['line_items'] ?? [], 'id'), $known)); + } + } + + /** + * A shipment is an event with tracking, and a refund is an adjustment that moves money the other + * way — both signed so the direction is in the value. + * + * @return void + */ + public function testAShippedAndRefundedOrderReportsBothLogs(): void + { + $order = self::loadFixture(self::SHIPPED_ORDER_FIXTURE); + $event = $order['fulfillment']['events'][0]; + $adjustment = $order['adjustments'][0]; + + $this->assertSame('shipped', $event['type']); + $this->assertNotEmpty($event['tracking_number']); + $this->assertSame('refund', $adjustment['type']); + $this->assertLessThan(0, $adjustment['totals'][0]['amount']); + $this->assertLessThan(0, $adjustment['line_items'][0]['quantity']); + } + + /** + * A returned quantity cannot still be in the buyer's hands, so what is reported fulfilled never + * exceeds what is still on the order. + * + * @return void + */ + public function testFulfilledQuantityNeverExceedsTheActiveQuantity(): void + { + foreach (self::loadFixture(self::SHIPPED_ORDER_FIXTURE)['line_items'] as $lineItem) { + $this->assertLessThanOrEqual($lineItem['quantity']['total'], $lineItem['quantity']['fulfilled']); + } + } + + /** + * The order is the continuation of a session the agent already holds, so it has to name that + * session and keep the line item identifiers the checkout gave it. + * + * @return void + */ + public function testTheOrderReconcilesWithTheCheckoutThatProducedIt(): void + { + $checkout = self::loadFixture('shopping.checkout_session.complete.200.json'); + $order = self::loadFixture(self::ORDER_FIXTURE); + + $this->assertSame($checkout['id'], $order['checkout_id']); + $this->assertSame($checkout['order']['id'], $order['id']); + $this->assertSame($checkout['order']['permalink_url'], $order['permalink_url']); + $this->assertSame( + array_column($checkout['line_items'], 'id'), + array_column($order['line_items'], 'id') + ); + } + + /** + * An order nothing has shipped for still tells the agent what to expect, and says the log is empty + * rather than leaving it out. + * + * @return void + */ + public function testAnUnshippedOrderReportsExpectationsAndAnEmptyEventLog(): void + { + $order = self::loadFixture(self::ORDER_FIXTURE); + + $this->assertNotEmpty($order['fulfillment']['expectations']); + $this->assertSame([], $order['fulfillment']['events']); + $this->assertSame('processing', $order['line_items'][0]['status']); + } + + /** + * An agent only sends for capabilities it can discover, so a populated extension must be advertised. + * + * @dataProvider checkoutFixtureProvider + * @param string $fixture + * @return void + */ + public function testEveryPopulatedExtensionIsAdvertised(string $fixture): void + { + $payload = self::loadFixture($fixture); + $advertised = array_keys($payload['ucp']['capabilities']); + + foreach (self::EXTENSION_FIELDS as $field => $capability) { + if (($payload[$field] ?? null) === null) { + continue; + } + + $this->assertContains($capability, $advertised, sprintf('"%s" is populated', $field)); + } + } + + /** + * An extension has to name the capability it extends; a root capability has to leave it out. + * + * @return void + */ + public function testExtensionsDeclareWhatTheyExtend(): void + { + $capabilities = self::loadFixture('shopping.checkout_session.get.200.json')['ucp']['capabilities']; + + foreach (self::EXTENSION_FIELDS as $capability) { + $this->assertSame( + 'dev.ucp.shopping.checkout', + $capabilities[$capability][0]['extends'] ?? null, + sprintf('"%s" declares its parent', $capability) + ); + } + + $this->assertArrayNotHasKey('extends', $capabilities['dev.ucp.shopping.checkout'][0]); + } + + /** + * @return array + */ + public static function cartFixtureProvider(): array + { + return [ + 'create 201' => ['shopping.cart.create.201.json'], + 'get 200' => ['shopping.cart.get.200.json'], + 'update 200' => ['shopping.cart.update.200.json'], + 'cancel 200' => ['shopping.cart.cancel.200.json'], + ]; + } + + /** + * @dataProvider cartFixtureProvider + * @param string $fixture + * @return void + */ + public function testCartResponseMatchesSpec(string $fixture): void + { + $this->assertMatchesSchema(self::loadFixtureObject($fixture), self::CART_SCHEMA); + } + + /** + * A cart is a smaller resource than a checkout, not a checkout with fields blanked. None of these + * belong on it, and its `ucp` block declares no payment handlers. + * + * @dataProvider cartFixtureProvider + * @param string $fixture + * @return void + */ + public function testACartCarriesNoCheckoutOnlyFields(string $fixture): void + { + $cart = self::loadFixture($fixture); + + foreach (['payment', 'status', 'fulfillment', 'order', 'cart_id'] as $field) { + $this->assertArrayNotHasKey($field, $cart, sprintf('"%s" is not a cart field', $field)); + } + + $this->assertArrayNotHasKey('payment_handlers', $cart['ucp']); + } + + /** + * Checkout reports missing buyer details as errors because it cannot proceed without them. A cart can, + * so the same findings are informational. + * + * @dataProvider cartFixtureProvider + * @param string $fixture + * @return void + */ + public function testACartReportsValidationFindingsAsInformational(string $fixture): void + { + foreach (self::loadFixture($fixture)['messages'] ?? [] as $message) { + $this->assertSame('info', $message['type']); + } + } + + /** + * A canceled cart is gone rather than a cart in a canceled state, so a later read is an error + * envelope and not a cart. + * + * @return void + */ + public function testReadingACanceledCartIsAnError(): void + { + $payload = self::loadFixture('shopping.cart.get.404.json'); + + $this->assertMatchesSchema(self::loadFixtureObject('shopping.cart.get.404.json'), self::ERROR_SCHEMA); + $this->assertSame('not_found', $payload['messages'][0]['code']); + } + + /** + * @return void + */ + public function testCatalogSearchMatchesSpec(): void + { + $this->assertMatchesSchema(self::loadFixtureObject('shopping.catalog.search.200.json'), self::SEARCH_SCHEMA); + } + + /** + * @return void + */ + public function testCatalogLookupMatchesSpec(): void + { + $this->assertMatchesSchema(self::loadFixtureObject('shopping.catalog.lookup.200.json'), self::LOOKUP_SCHEMA); + } + + /** + * @return void + */ + public function testCatalogProductMatchesSpec(): void + { + $this->assertMatchesSchema(self::loadFixtureObject('shopping.catalog.product.200.json'), self::PRODUCT_SCHEMA); + } + + /** + * An agent buys a variant, so every product carries at least one even when it has no option axes. + * + * @return void + */ + public function testEveryProductHasAVariant(): void + { + foreach (self::loadFixture('shopping.catalog.search.200.json')['products'] as $product) { + $this->assertNotEmpty($product['variants'], $product['id'] . ' has a variant'); + } + } + + /** + * An out-of-stock product is reported rather than hidden, so an agent learns it exists and cannot be + * bought instead of being told nothing. Magento would drop it from the page but not from the count. + * + * @return void + */ + public function testOutOfStockProductsAreReportedNotHidden(): void + { + $search = self::loadFixture('shopping.catalog.search.200.json'); + $statuses = []; + + foreach ($search['products'] as $product) { + $statuses[] = $product['variants'][0]['availability']['status']; + } + + $this->assertContains('out_of_stock', $statuses); + $this->assertCount($search['pagination']['total_count'], $search['products']); + } + + /** + * A lookup variant has to say which requested identifier resolved to it. + * + * @return void + */ + public function testLookupCorrelatesEveryVariantToTheRequestedId(): void + { + foreach (self::loadFixture('shopping.catalog.lookup.200.json')['products'] as $product) { + foreach ($product['variants'] as $variant) { + $this->assertNotEmpty($variant['inputs']); + $this->assertContains($variant['inputs'][0]['match'], ['exact', 'featured']); + } + } + } + + /** + * An unknown id is left out of a lookup rather than failing the batch, but a direct product read for + * one is an error. + * + * @return void + */ + public function testAnUnknownProductIsAnError(): void + { + $payload = self::loadFixture('shopping.catalog.product.404.json'); + + $this->assertMatchesSchema(self::loadFixtureObject('shopping.catalog.product.404.json'), self::ERROR_SCHEMA); + $this->assertSame('not_found', $payload['messages'][0]['code']); + } + + /** + * @return array + */ + public static function errorFixtureProvider(): array + { + return [ + 'unknown session 404' => ['shopping.checkout_session.get.404.json'], + 'invalid body 400' => ['shopping.checkout_session.create.400.json'], + ]; + } + + /** + * The error envelope forbids additional properties, so a captured failure is the only thing that + * proves the boundary is not still sending its own invented fields. + * + * @dataProvider errorFixtureProvider + * @param string $fixture + * @return void + */ + public function testErrorResponseMatchesSpec(string $fixture): void + { + $this->assertMatchesSchema(self::loadFixtureObject($fixture), self::ERROR_SCHEMA); + } + + /** + * @return void + */ + public function testAValidationErrorPointsAtTheOffendingFieldAsAJsonPath(): void + { + $payload = self::loadFixture('shopping.checkout_session.create.400.json'); + + $this->assertSame('$.line_items', $payload['messages'][0]['path']); + } + /** * @return void */ diff --git a/Test/Unit/SpecSchemaValidator.php b/Test/Unit/SpecSchemaValidator.php index 7bfc49b..0b706cc 100644 --- a/Test/Unit/SpecSchemaValidator.php +++ b/Test/Unit/SpecSchemaValidator.php @@ -34,18 +34,37 @@ class SpecSchemaValidator */ public const SCHEMA_DIR = 'libraries/ucp-php-spec/spec/schemas'; + /** + * A class from the installed specification package, used to find where that package lives. + */ + public const RUNTIME_CLASS = 'Magebit\UcpSpec\Runtime\SpecObject'; + + /** + * Where the schemas sit inside that package. + */ + public const PACKAGE_SCHEMA_DIR = 'spec/schemas'; + /** * Collect a whole batch of divergences per run instead of only the first. */ public const MAX_ERRORS = 50; /** - * Walks up from this file to find the project root holding the vendored spec. + * The schemas ship inside the installed specification package, so they are found through it + * rather than through a path. Walking up for a directory only this checkout has meant the + * schema tests skipped themselves everywhere else, reporting green while covering nothing. * * @return string|null */ public static function locateSchemaDir(): ?string { + $installed = self::installedSchemaDir(); + + if ($installed !== null) { + return $installed; + } + + // Falls back to the working copy, for anyone editing the specification library in place. $dir = __DIR__; while (true) { @@ -65,6 +84,30 @@ public static function locateSchemaDir(): ?string } } + /** + * @return string|null + */ + private static function installedSchemaDir(): ?string + { + if (!class_exists(self::RUNTIME_CLASS)) { + return null; + } + + try { + $file = (new \ReflectionClass(self::RUNTIME_CLASS))->getFileName(); + } catch (\ReflectionException $exception) { + return null; + } + + if ($file === false) { + return null; + } + + $candidate = dirname($file, 2) . '/' . self::PACKAGE_SCHEMA_DIR; + + return is_dir($candidate) ? $candidate : null; + } + /** * Splits "file.json#/pointer" and returns the absolute file path, or null when absent. * diff --git a/Test/Unit/SpecTypeBindingTest.php b/Test/Unit/SpecTypeBindingTest.php new file mode 100644 index 0000000..be2fa68 --- /dev/null +++ b/Test/Unit/SpecTypeBindingTest.php @@ -0,0 +1,82 @@ + + * @license MIT + */ + +declare(strict_types=1); + +namespace Magebit\UniversalCommerce\Test\Unit; + +use PHPUnit\Framework\TestCase; + +/** + * Every generated spec interface the module builds through a factory needs a DI preference. Without one + * the failure is a runtime "cannot instantiate interface" on the first request that reaches the code, + * which has happened repeatedly. + */ +class SpecTypeBindingTest extends TestCase +{ + /** + * @return void + */ + public function testEverySpecFactoryHasAPreference(): void + { + $moduleDir = dirname(__DIR__, 2); + $di = (string) file_get_contents($moduleDir . '/etc/di.xml'); + $unbound = []; + + foreach ($this->specFactoryInterfaces($moduleDir) as $interface => $sites) { + if (!str_contains($di, 'for="' . $interface . '"')) { + $unbound[$interface] = $sites; + } + } + + $this->assertSame([], $unbound, sprintf( + "These spec interfaces are built through a factory but have no DI preference:\n%s", + implode("\n", array_map( + static fn (string $i, array $s): string => ' ' . $i . ' (' . implode(', ', $s) . ')', + array_keys($unbound), + $unbound + )) + )); + } + + /** + * @param string $moduleDir + * @return array> + */ + private function specFactoryInterfaces(string $moduleDir): array + { + $found = []; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($moduleDir)); + + foreach ($iterator as $file) { + if (!$file instanceof \SplFileInfo || $file->getExtension() !== 'php') { + continue; + } + + $path = $file->getPathname(); + + // Tests wire their own doubles, so a factory used only there needs no container binding. + if (str_contains($path, '/Test/')) { + continue; + } + + $source = (string) file_get_contents($path); + preg_match_all('~use (Magebit\\\\UcpSpec\\\\Api\\\\[A-Za-z0-9_\\\\]+)InterfaceFactory;~', $source, $matches); + + foreach ($matches[1] as $base) { + $found[$base . 'Interface'][] = basename($path); + } + } + + ksort($found); + + return $found; + } +} diff --git a/Test/Unit/_fixtures/discovery.well_known_ucp.200.json b/Test/Unit/_fixtures/discovery.well_known_ucp.200.json index 7eb97df..d8b78a8 100644 --- a/Test/Unit/_fixtures/discovery.well_known_ucp.200.json +++ b/Test/Unit/_fixtures/discovery.well_known_ucp.200.json @@ -1,10 +1,10 @@ { "ucp": { - "version": "2026-01-23", + "version": "2026-04-08", "services": { "dev.ucp.shopping": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specs/shopping", "schema": "https://ucp.dev/services/shopping/openapi.json", "transport": "rest", @@ -15,18 +15,33 @@ "capabilities": { "dev.ucp.shopping.checkout": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specs/shopping/checkout", "schema": "https://ucp.dev/schemas/shopping/checkout.json" } ], "dev.ucp.shopping.discount": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specification/discount", "schema": "https://ucp.dev/schemas/shopping/discount.json", "extends": "dev.ucp.shopping.checkout" } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } ] }, "payment_handlers": {} diff --git a/Test/Unit/_fixtures/shopping.cart.cancel.200.json b/Test/Unit/_fixtures/shopping.cart.cancel.200.json new file mode 100644 index 0000000..cd2e660 --- /dev/null +++ b/Test/Unit/_fixtures/shopping.cart.cancel.200.json @@ -0,0 +1,133 @@ +{ + "ucp": { + "version": "2026-04-08", + "capabilities": { + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/cart", + "schema": "https://ucp.dev/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "id": "checkout_session_placeholder_0001", + "line_items": [ + { + "id": "id-7ed8f0f3", + "quantity": 5, + "item": { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, + "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + }, + "totals": [ + { + "type": "subtotal", + "amount": 10000, + "display_text": "Subtotal" + }, + { + "type": "total", + "amount": 10000, + "display_text": "Total" + } + ] + } + ], + "currency": "USD", + "totals": [ + { + "type": "subtotal", + "display_text": "Subtotal", + "amount": 10000 + }, + { + "type": "fulfillment", + "display_text": "Shipping & Handling", + "amount": 0 + }, + { + "type": "tax", + "display_text": "Tax", + "amount": 0 + }, + { + "type": "total", + "display_text": "Grand Total", + "amount": 10000 + } + ], + "links": [], + "continue_url": "https://merchant.example/ucp/checkout/resume/id/6svLdhMURfwdeTUHSkixD8a5ZMnySHHs", + "expires_at": "2026-01-01T00:00:00+00:00", + "messages": [ + { + "type": "info", + "code": "missing", + "content": "First name is required", + "path": "$.buyer.first_name" + }, + { + "type": "info", + "code": "missing", + "content": "Last name is required", + "path": "$.buyer.last_name" + }, + { + "type": "info", + "code": "missing", + "content": "Telephone is required", + "path": "$.buyer.phone_number" + }, + { + "type": "info", + "code": "missing", + "content": "City is required", + "path": "$.fulfillment.methods[0].destination.address_locality" + }, + { + "type": "info", + "code": "missing", + "content": "Country is required", + "path": "$.fulfillment.methods[0].destination.address_country" + }, + { + "type": "info", + "code": "missing", + "content": "Postcode is required", + "path": "$.fulfillment.methods[0].destination.postal_code" + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.cart.create.201.json b/Test/Unit/_fixtures/shopping.cart.create.201.json new file mode 100644 index 0000000..1e3682d --- /dev/null +++ b/Test/Unit/_fixtures/shopping.cart.create.201.json @@ -0,0 +1,133 @@ +{ + "ucp": { + "version": "2026-04-08", + "capabilities": { + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/cart", + "schema": "https://ucp.dev/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "id": "checkout_session_placeholder_0001", + "line_items": [ + { + "id": "id-c75de23d", + "quantity": 2, + "item": { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, + "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + }, + "totals": [ + { + "type": "subtotal", + "amount": 4000, + "display_text": "Subtotal" + }, + { + "type": "total", + "amount": 4000, + "display_text": "Total" + } + ] + } + ], + "currency": "USD", + "totals": [ + { + "type": "subtotal", + "display_text": "Subtotal", + "amount": 4000 + }, + { + "type": "fulfillment", + "display_text": "Shipping & Handling", + "amount": 0 + }, + { + "type": "tax", + "display_text": "Tax", + "amount": 0 + }, + { + "type": "total", + "display_text": "Grand Total", + "amount": 4000 + } + ], + "links": [], + "continue_url": "https://merchant.example/ucp/checkout/resume/id/6svLdhMURfwdeTUHSkixD8a5ZMnySHHs", + "expires_at": "2026-01-01T00:00:00+00:00", + "messages": [ + { + "type": "info", + "code": "missing", + "content": "First name is required", + "path": "$.buyer.first_name" + }, + { + "type": "info", + "code": "missing", + "content": "Last name is required", + "path": "$.buyer.last_name" + }, + { + "type": "info", + "code": "missing", + "content": "Telephone is required", + "path": "$.buyer.phone_number" + }, + { + "type": "info", + "code": "missing", + "content": "City is required", + "path": "$.fulfillment.methods[0].destination.address_locality" + }, + { + "type": "info", + "code": "missing", + "content": "Country is required", + "path": "$.fulfillment.methods[0].destination.address_country" + }, + { + "type": "info", + "code": "missing", + "content": "Postcode is required", + "path": "$.fulfillment.methods[0].destination.postal_code" + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.cart.get.200.json b/Test/Unit/_fixtures/shopping.cart.get.200.json new file mode 100644 index 0000000..1e3682d --- /dev/null +++ b/Test/Unit/_fixtures/shopping.cart.get.200.json @@ -0,0 +1,133 @@ +{ + "ucp": { + "version": "2026-04-08", + "capabilities": { + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/cart", + "schema": "https://ucp.dev/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "id": "checkout_session_placeholder_0001", + "line_items": [ + { + "id": "id-c75de23d", + "quantity": 2, + "item": { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, + "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + }, + "totals": [ + { + "type": "subtotal", + "amount": 4000, + "display_text": "Subtotal" + }, + { + "type": "total", + "amount": 4000, + "display_text": "Total" + } + ] + } + ], + "currency": "USD", + "totals": [ + { + "type": "subtotal", + "display_text": "Subtotal", + "amount": 4000 + }, + { + "type": "fulfillment", + "display_text": "Shipping & Handling", + "amount": 0 + }, + { + "type": "tax", + "display_text": "Tax", + "amount": 0 + }, + { + "type": "total", + "display_text": "Grand Total", + "amount": 4000 + } + ], + "links": [], + "continue_url": "https://merchant.example/ucp/checkout/resume/id/6svLdhMURfwdeTUHSkixD8a5ZMnySHHs", + "expires_at": "2026-01-01T00:00:00+00:00", + "messages": [ + { + "type": "info", + "code": "missing", + "content": "First name is required", + "path": "$.buyer.first_name" + }, + { + "type": "info", + "code": "missing", + "content": "Last name is required", + "path": "$.buyer.last_name" + }, + { + "type": "info", + "code": "missing", + "content": "Telephone is required", + "path": "$.buyer.phone_number" + }, + { + "type": "info", + "code": "missing", + "content": "City is required", + "path": "$.fulfillment.methods[0].destination.address_locality" + }, + { + "type": "info", + "code": "missing", + "content": "Country is required", + "path": "$.fulfillment.methods[0].destination.address_country" + }, + { + "type": "info", + "code": "missing", + "content": "Postcode is required", + "path": "$.fulfillment.methods[0].destination.postal_code" + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.cart.get.404.json b/Test/Unit/_fixtures/shopping.cart.get.404.json new file mode 100644 index 0000000..f70ae79 --- /dev/null +++ b/Test/Unit/_fixtures/shopping.cart.get.404.json @@ -0,0 +1,14 @@ +{ + "ucp": { + "version": "2026-04-08", + "status": "error" + }, + "messages": [ + { + "type": "error", + "code": "not_found", + "content": "Cart not found: 6svLdhMURfwdeTUHSkixD8a5ZMnySHHs.", + "severity": "recoverable" + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.cart.update.200.json b/Test/Unit/_fixtures/shopping.cart.update.200.json new file mode 100644 index 0000000..cd2e660 --- /dev/null +++ b/Test/Unit/_fixtures/shopping.cart.update.200.json @@ -0,0 +1,133 @@ +{ + "ucp": { + "version": "2026-04-08", + "capabilities": { + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/cart", + "schema": "https://ucp.dev/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "id": "checkout_session_placeholder_0001", + "line_items": [ + { + "id": "id-7ed8f0f3", + "quantity": 5, + "item": { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, + "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + }, + "totals": [ + { + "type": "subtotal", + "amount": 10000, + "display_text": "Subtotal" + }, + { + "type": "total", + "amount": 10000, + "display_text": "Total" + } + ] + } + ], + "currency": "USD", + "totals": [ + { + "type": "subtotal", + "display_text": "Subtotal", + "amount": 10000 + }, + { + "type": "fulfillment", + "display_text": "Shipping & Handling", + "amount": 0 + }, + { + "type": "tax", + "display_text": "Tax", + "amount": 0 + }, + { + "type": "total", + "display_text": "Grand Total", + "amount": 10000 + } + ], + "links": [], + "continue_url": "https://merchant.example/ucp/checkout/resume/id/6svLdhMURfwdeTUHSkixD8a5ZMnySHHs", + "expires_at": "2026-01-01T00:00:00+00:00", + "messages": [ + { + "type": "info", + "code": "missing", + "content": "First name is required", + "path": "$.buyer.first_name" + }, + { + "type": "info", + "code": "missing", + "content": "Last name is required", + "path": "$.buyer.last_name" + }, + { + "type": "info", + "code": "missing", + "content": "Telephone is required", + "path": "$.buyer.phone_number" + }, + { + "type": "info", + "code": "missing", + "content": "City is required", + "path": "$.fulfillment.methods[0].destination.address_locality" + }, + { + "type": "info", + "code": "missing", + "content": "Country is required", + "path": "$.fulfillment.methods[0].destination.address_country" + }, + { + "type": "info", + "code": "missing", + "content": "Postcode is required", + "path": "$.fulfillment.methods[0].destination.postal_code" + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.catalog.lookup.200.json b/Test/Unit/_fixtures/shopping.catalog.lookup.200.json new file mode 100644 index 0000000..ca7fe1a --- /dev/null +++ b/Test/Unit/_fixtures/shopping.catalog.lookup.200.json @@ -0,0 +1,108 @@ +{ + "ucp": { + "version": "2026-04-08", + "capabilities": { + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/cart", + "schema": "https://ucp.dev/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/catalog-lookup", + "schema": "https://ucp.dev/schemas/shopping/catalog_lookup.json" + } + ], + "dev.ucp.shopping.catalog.search": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/catalog-search", + "schema": "https://ucp.dev/schemas/shopping/catalog_search.json" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "products": [ + { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "description": { + "plain": "" + }, + "price_range": { + "min": { + "amount": 2000, + "currency": "USD" + }, + "max": { + "amount": 2000, + "currency": "USD" + } + }, + "variants": [ + { + "id": "ucp-conformance-simple", + "sku": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "description": { + "plain": "" + }, + "price": { + "amount": 2000, + "currency": "USD" + }, + "availability": { + "available": true, + "status": "in_stock" + }, + "inputs": [ + { + "id": "ucp-conformance-simple", + "match": "exact" + } + ] + } + ], + "url": "https://merchant.example/ucp-conformance-simple-product.html", + "media": [ + { + "type": "image", + "url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + } + ] + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.catalog.product.200.json b/Test/Unit/_fixtures/shopping.catalog.product.200.json new file mode 100644 index 0000000..b86eb1e --- /dev/null +++ b/Test/Unit/_fixtures/shopping.catalog.product.200.json @@ -0,0 +1,100 @@ +{ + "ucp": { + "version": "2026-04-08", + "capabilities": { + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/cart", + "schema": "https://ucp.dev/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/catalog-lookup", + "schema": "https://ucp.dev/schemas/shopping/catalog_lookup.json" + } + ], + "dev.ucp.shopping.catalog.search": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/catalog-search", + "schema": "https://ucp.dev/schemas/shopping/catalog_search.json" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "product": { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "description": { + "plain": "" + }, + "price_range": { + "min": { + "amount": 2000, + "currency": "USD" + }, + "max": { + "amount": 2000, + "currency": "USD" + } + }, + "variants": [ + { + "id": "ucp-conformance-simple", + "sku": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "description": { + "plain": "" + }, + "price": { + "amount": 2000, + "currency": "USD" + }, + "availability": { + "available": true, + "status": "in_stock" + } + } + ], + "url": "https://merchant.example/ucp-conformance-simple-product.html", + "media": [ + { + "type": "image", + "url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + } + ] + } +} diff --git a/Test/Unit/_fixtures/shopping.catalog.product.404.json b/Test/Unit/_fixtures/shopping.catalog.product.404.json new file mode 100644 index 0000000..5d0c442 --- /dev/null +++ b/Test/Unit/_fixtures/shopping.catalog.product.404.json @@ -0,0 +1,14 @@ +{ + "ucp": { + "version": "2026-04-08", + "status": "error" + }, + "messages": [ + { + "type": "error", + "code": "not_found", + "content": "Product not found: nope.", + "severity": "recoverable" + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.catalog.search.200.json b/Test/Unit/_fixtures/shopping.catalog.search.200.json new file mode 100644 index 0000000..57673ad --- /dev/null +++ b/Test/Unit/_fixtures/shopping.catalog.search.200.json @@ -0,0 +1,190 @@ +{ + "ucp": { + "version": "2026-04-08", + "capabilities": { + "dev.ucp.shopping.cart": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/cart", + "schema": "https://ucp.dev/schemas/shopping/cart.json" + } + ], + "dev.ucp.shopping.catalog.lookup": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/catalog-lookup", + "schema": "https://ucp.dev/schemas/shopping/catalog_lookup.json" + } + ], + "dev.ucp.shopping.catalog.search": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/catalog-search", + "schema": "https://ucp.dev/schemas/shopping/catalog_search.json" + } + ], + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "products": [ + { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "description": { + "plain": "" + }, + "price_range": { + "min": { + "amount": 2000, + "currency": "USD" + }, + "max": { + "amount": 2000, + "currency": "USD" + } + }, + "variants": [ + { + "id": "ucp-conformance-simple", + "sku": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "description": { + "plain": "" + }, + "price": { + "amount": 2000, + "currency": "USD" + }, + "availability": { + "available": true, + "status": "in_stock" + } + } + ], + "url": "https://merchant.example/ucp-conformance-simple-product.html", + "media": [ + { + "type": "image", + "url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + } + ] + }, + { + "id": "ucp-conformance-second", + "title": "UCP Conformance Second Product", + "description": { + "plain": "" + }, + "price_range": { + "min": { + "amount": 3550, + "currency": "USD" + }, + "max": { + "amount": 3550, + "currency": "USD" + } + }, + "variants": [ + { + "id": "ucp-conformance-second", + "sku": "ucp-conformance-second", + "title": "UCP Conformance Second Product", + "description": { + "plain": "" + }, + "price": { + "amount": 3550, + "currency": "USD" + }, + "availability": { + "available": true, + "status": "in_stock" + } + } + ], + "url": "https://merchant.example/ucp-conformance-second-product.html", + "media": [ + { + "type": "image", + "url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + } + ] + }, + { + "id": "ucp-conformance-oos", + "title": "UCP Conformance Out Of Stock Product", + "description": { + "plain": "" + }, + "price_range": { + "min": { + "amount": 1500, + "currency": "USD" + }, + "max": { + "amount": 1500, + "currency": "USD" + } + }, + "variants": [ + { + "id": "ucp-conformance-oos", + "sku": "ucp-conformance-oos", + "title": "UCP Conformance Out Of Stock Product", + "description": { + "plain": "" + }, + "price": { + "amount": 1500, + "currency": "USD" + }, + "availability": { + "available": false, + "status": "out_of_stock" + } + } + ], + "url": "https://merchant.example/ucp-conformance-out-of-stock-product.html", + "media": [ + { + "type": "image", + "url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + } + ] + } + ], + "pagination": { + "has_next_page": false, + "total_count": 3 + } +} diff --git a/Test/Unit/_fixtures/shopping.checkout_session.cancel.200.json b/Test/Unit/_fixtures/shopping.checkout_session.cancel.200.json index 1622198..4aeb976 100644 --- a/Test/Unit/_fixtures/shopping.checkout_session.cancel.200.json +++ b/Test/Unit/_fixtures/shopping.checkout_session.cancel.200.json @@ -2,23 +2,23 @@ "id": "checkout_session_placeholder_0001", "line_items": [ { - "id": "1001", + "id": "id-d6a40317", "quantity": 2, "item": { - "id": "24-MB04", - "title": "Strive Shoulder Pack", - "price": 3200, + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" }, "totals": [ { "type": "subtotal", - "amount": 6400, + "amount": 4000, "display_text": "Subtotal" }, { "type": "total", - "amount": 6400, + "amount": 4000, "display_text": "Total" } ] @@ -31,22 +31,37 @@ "phone_number": "+15555550123" }, "ucp": { - "version": "2026-01-23", + "version": "2026-04-08", "capabilities": { "dev.ucp.shopping.checkout": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specs/shopping/checkout", "schema": "https://ucp.dev/schemas/shopping/checkout.json" } ], "dev.ucp.shopping.discount": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specification/discount", "schema": "https://ucp.dev/schemas/shopping/discount.json", "extends": "dev.ucp.shopping.checkout" } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } ] }, "payment_handlers": {} @@ -56,12 +71,12 @@ { "type": "subtotal", "display_text": "Subtotal", - "amount": 6400 + "amount": 4000 }, { "type": "fulfillment", - "display_text": "Shipping & Handling", - "amount": 0 + "display_text": "Shipping & Handling (Flat Rate - Fixed)", + "amount": 1000 }, { "type": "tax", @@ -71,7 +86,7 @@ { "type": "total", "display_text": "Grand Total", - "amount": 6400 + "amount": 5000 } ], "links": [], @@ -79,37 +94,68 @@ "instruments": [] }, "fulfillment": { - "methods": [] + "methods": [ + { + "id": "shipping", + "type": "shipping", + "line_item_ids": [ + "id-d6a40317" + ], + "destinations": [ + { + "id": "dest-1", + "street_address": "1 Infinite Loop", + "address_locality": "Cupertino", + "address_region": "California", + "address_country": "US", + "postal_code": "95014", + "first_name": "Ada", + "last_name": "Lovelace", + "phone_number": "+15555550123" + } + ], + "selected_destination_id": "dest-1", + "groups": [ + { + "id": "package", + "line_item_ids": [ + "id-d6a40317" + ], + "options": [ + { + "id": "flatrate_flatrate", + "title": "Fixed", + "description": "Flat Rate", + "carrier": "Flat Rate", + "totals": [ + { + "type": "fulfillment", + "amount": 1000, + "display_text": "Fixed" + } + ] + }, + { + "id": "tablerate_bestway", + "title": "Table Rate", + "description": "Best Way", + "carrier": "Best Way", + "totals": [ + { + "type": "fulfillment", + "amount": 1500, + "display_text": "Table Rate" + } + ] + } + ], + "selected_option_id": "flatrate_flatrate" + } + ] + } + ] }, - "messages": [ - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_locality", - "code": "missing", - "severity": "requires_buyer_input", - "content": "City is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_country", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Country is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.postal_code", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Postcode is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_region", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Region is required" - } - ], + "continue_url": "https://merchant.example/ucp/checkout/resume/id/x6JvCZLgDHN0EluhV6odK1tGa23WfCbD", + "messages": [], "status": "canceled" } diff --git a/Test/Unit/_fixtures/shopping.checkout_session.complete.200.json b/Test/Unit/_fixtures/shopping.checkout_session.complete.200.json index 1c0ae83..cab7db0 100644 --- a/Test/Unit/_fixtures/shopping.checkout_session.complete.200.json +++ b/Test/Unit/_fixtures/shopping.checkout_session.complete.200.json @@ -2,23 +2,23 @@ "id": "checkout_session_placeholder_0001", "line_items": [ { - "id": "1001", - "quantity": 1, + "id": "id-d80eae6e", + "quantity": 2, "item": { - "id": "24-MB04", - "title": "Strive Shoulder Pack", - "price": 3200, + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" }, "totals": [ { "type": "subtotal", - "amount": 3200, + "amount": 4000, "display_text": "Subtotal" }, { "type": "total", - "amount": 3200, + "amount": 4000, "display_text": "Total" } ] @@ -31,22 +31,37 @@ "phone_number": "+15555550123" }, "ucp": { - "version": "2026-01-23", + "version": "2026-04-08", "capabilities": { "dev.ucp.shopping.checkout": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specs/shopping/checkout", "schema": "https://ucp.dev/schemas/shopping/checkout.json" } ], "dev.ucp.shopping.discount": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specification/discount", "schema": "https://ucp.dev/schemas/shopping/discount.json", "extends": "dev.ucp.shopping.checkout" } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } ] }, "payment_handlers": {} @@ -56,12 +71,12 @@ { "type": "subtotal", "display_text": "Subtotal", - "amount": 3200 + "amount": 4000 }, { "type": "fulfillment", "display_text": "Shipping & Handling (Flat Rate - Fixed)", - "amount": 500 + "amount": 1000 }, { "type": "tax", @@ -71,7 +86,7 @@ { "type": "total", "display_text": "Grand Total", - "amount": 3700 + "amount": 5000 } ], "links": [], @@ -84,27 +99,27 @@ "id": "shipping", "type": "shipping", "line_item_ids": [ - "86" + "id-d80eae6e" ], "destinations": [ { - "id": "1002", - "street_address": "1 Main St", - "address_locality": "Austin", - "address_region": "Texas", + "id": "dest-1", + "street_address": "1 Infinite Loop", + "address_locality": "Cupertino", + "address_region": "California", "address_country": "US", - "postal_code": "78701", + "postal_code": "95014", "first_name": "Ada", "last_name": "Lovelace", "phone_number": "+15555550123" } ], - "selected_destination_id": "122", + "selected_destination_id": "dest-1", "groups": [ { "id": "package", "line_item_ids": [ - "86" + "id-d80eae6e" ], "options": [ { @@ -115,7 +130,7 @@ "totals": [ { "type": "fulfillment", - "amount": 500, + "amount": 1000, "display_text": "Fixed" } ] @@ -141,9 +156,11 @@ ] }, "order": { - "id": "1003", + "id": "id-8f6482bf", "permalink_url": "https://merchant.example/sales/order/view/order_id/1000" }, + "expires_at": "2026-01-01T00:00:00+00:00", + "continue_url": "https://merchant.example/ucp/checkout/resume/id/gyqnDxUCnKrhSBAGEUefgzLNbOP2TiAU", "messages": [], "status": "completed" } diff --git a/Test/Unit/_fixtures/shopping.checkout_session.create.201.json b/Test/Unit/_fixtures/shopping.checkout_session.create.201.json index 8f9dbb4..d1b2c4b 100644 --- a/Test/Unit/_fixtures/shopping.checkout_session.create.201.json +++ b/Test/Unit/_fixtures/shopping.checkout_session.create.201.json @@ -2,23 +2,23 @@ "id": "checkout_session_placeholder_0001", "line_items": [ { - "id": "1001", + "id": "id-36ebe205", "quantity": 2, "item": { - "id": "24-MB04", - "title": "Strive Shoulder Pack", - "price": 3200, - "image_url": "https://merchant.example/media/catalog/product/cache/IMAGECACHEHASH/m/b/mb04-black-0.jpg" + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, + "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" }, "totals": [ { "type": "subtotal", - "amount": 6400, + "amount": 4000, "display_text": "Subtotal" }, { "type": "total", - "amount": 6400, + "amount": 4000, "display_text": "Total" } ] @@ -31,22 +31,37 @@ "phone_number": "+15555550123" }, "ucp": { - "version": "2026-01-23", + "version": "2026-04-08", "capabilities": { "dev.ucp.shopping.checkout": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specs/shopping/checkout", "schema": "https://ucp.dev/schemas/shopping/checkout.json" } ], "dev.ucp.shopping.discount": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specification/discount", "schema": "https://ucp.dev/schemas/shopping/discount.json", "extends": "dev.ucp.shopping.checkout" } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } ] }, "payment_handlers": {} @@ -56,12 +71,12 @@ { "type": "subtotal", "display_text": "Subtotal", - "amount": 6400 + "amount": 4000 }, { "type": "fulfillment", - "display_text": "Shipping & Handling", - "amount": 0 + "display_text": "Shipping & Handling (Flat Rate - Fixed)", + "amount": 1000 }, { "type": "tax", @@ -71,7 +86,7 @@ { "type": "total", "display_text": "Grand Total", - "amount": 6400 + "amount": 5000 } ], "links": [], @@ -79,37 +94,68 @@ "instruments": [] }, "fulfillment": { - "methods": [] + "methods": [ + { + "id": "shipping", + "type": "shipping", + "line_item_ids": [ + "id-36ebe205" + ], + "destinations": [ + { + "id": "dest-1", + "street_address": "1 Infinite Loop", + "address_locality": "Cupertino", + "address_region": "California", + "address_country": "US", + "postal_code": "95014", + "first_name": "Ada", + "last_name": "Lovelace", + "phone_number": "+15555550123" + } + ], + "selected_destination_id": "dest-1", + "groups": [ + { + "id": "package", + "line_item_ids": [ + "id-36ebe205" + ], + "options": [ + { + "id": "flatrate_flatrate", + "title": "Fixed", + "description": "Flat Rate", + "carrier": "Flat Rate", + "totals": [ + { + "type": "fulfillment", + "amount": 1000, + "display_text": "Fixed" + } + ] + }, + { + "id": "tablerate_bestway", + "title": "Table Rate", + "description": "Best Way", + "carrier": "Best Way", + "totals": [ + { + "type": "fulfillment", + "amount": 1500, + "display_text": "Table Rate" + } + ] + } + ], + "selected_option_id": "flatrate_flatrate" + } + ] + } + ] }, - "messages": [ - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_locality", - "code": "missing", - "severity": "requires_buyer_input", - "content": "City is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_country", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Country is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.postal_code", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Postcode is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_region", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Region is required" - } - ], - "status": "incomplete" + "continue_url": "https://merchant.example/ucp/checkout/resume/id/gyqnDxUCnKrhSBAGEUefgzLNbOP2TiAU", + "messages": [], + "status": "ready_for_complete" } diff --git a/Test/Unit/_fixtures/shopping.checkout_session.create.400.json b/Test/Unit/_fixtures/shopping.checkout_session.create.400.json new file mode 100644 index 0000000..a92f214 --- /dev/null +++ b/Test/Unit/_fixtures/shopping.checkout_session.create.400.json @@ -0,0 +1,15 @@ +{ + "ucp": { + "version": "2026-04-08", + "status": "error" + }, + "messages": [ + { + "type": "error", + "code": "validation_error", + "content": "Required field \"line_items\" is missing", + "severity": "requires_buyer_input", + "path": "$.line_items" + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.checkout_session.get.200.json b/Test/Unit/_fixtures/shopping.checkout_session.get.200.json index 601b054..acb6278 100644 --- a/Test/Unit/_fixtures/shopping.checkout_session.get.200.json +++ b/Test/Unit/_fixtures/shopping.checkout_session.get.200.json @@ -2,23 +2,23 @@ "id": "checkout_session_placeholder_0001", "line_items": [ { - "id": "1001", + "id": "id-d80eae6e", "quantity": 2, "item": { - "id": "24-MB04", - "title": "Strive Shoulder Pack", - "price": 3200, + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" }, "totals": [ { "type": "subtotal", - "amount": 6400, + "amount": 4000, "display_text": "Subtotal" }, { "type": "total", - "amount": 6400, + "amount": 4000, "display_text": "Total" } ] @@ -31,22 +31,37 @@ "phone_number": "+15555550123" }, "ucp": { - "version": "2026-01-23", + "version": "2026-04-08", "capabilities": { "dev.ucp.shopping.checkout": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specs/shopping/checkout", "schema": "https://ucp.dev/schemas/shopping/checkout.json" } ], "dev.ucp.shopping.discount": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specification/discount", "schema": "https://ucp.dev/schemas/shopping/discount.json", "extends": "dev.ucp.shopping.checkout" } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } ] }, "payment_handlers": {} @@ -56,7 +71,7 @@ { "type": "subtotal", "display_text": "Subtotal", - "amount": 6400 + "amount": 4000 }, { "type": "fulfillment", @@ -71,7 +86,7 @@ { "type": "total", "display_text": "Grand Total", - "amount": 6400 + "amount": 5000 } ], "links": [], @@ -79,37 +94,69 @@ "instruments": [] }, "fulfillment": { - "methods": [] + "methods": [ + { + "id": "shipping", + "type": "shipping", + "line_item_ids": [ + "id-d80eae6e" + ], + "destinations": [ + { + "id": "dest-1", + "street_address": "1 Infinite Loop", + "address_locality": "Cupertino", + "address_region": "California", + "address_country": "US", + "postal_code": "95014", + "first_name": "Ada", + "last_name": "Lovelace", + "phone_number": "+15555550123" + } + ], + "selected_destination_id": "dest-1", + "groups": [ + { + "id": "package", + "line_item_ids": [ + "id-d80eae6e" + ], + "options": [ + { + "id": "flatrate_flatrate", + "title": "Fixed", + "description": "Flat Rate", + "carrier": "Flat Rate", + "totals": [ + { + "type": "fulfillment", + "amount": 1000, + "display_text": "Fixed" + } + ] + }, + { + "id": "tablerate_bestway", + "title": "Table Rate", + "description": "Best Way", + "carrier": "Best Way", + "totals": [ + { + "type": "fulfillment", + "amount": 1500, + "display_text": "Table Rate" + } + ] + } + ], + "selected_option_id": "flatrate_flatrate" + } + ] + } + ] }, - "messages": [ - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_locality", - "code": "missing", - "severity": "requires_buyer_input", - "content": "City is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_country", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Country is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.postal_code", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Postcode is required" - }, - { - "type": "error", - "path": "$.fulfillment.methods[0].destination.address_region", - "code": "missing", - "severity": "requires_buyer_input", - "content": "Region is required" - } - ], - "status": "incomplete" + "expires_at": "2026-01-01T00:00:00+00:00", + "continue_url": "https://merchant.example/ucp/checkout/resume/id/gyqnDxUCnKrhSBAGEUefgzLNbOP2TiAU", + "messages": [], + "status": "ready_for_complete" } diff --git a/Test/Unit/_fixtures/shopping.checkout_session.get.404.json b/Test/Unit/_fixtures/shopping.checkout_session.get.404.json new file mode 100644 index 0000000..a493ed1 --- /dev/null +++ b/Test/Unit/_fixtures/shopping.checkout_session.get.404.json @@ -0,0 +1,14 @@ +{ + "ucp": { + "version": "2026-04-08", + "status": "error" + }, + "messages": [ + { + "type": "error", + "code": "session_not_found", + "content": "Checkout session not found: does-not-exist. Please create a new checkout session.", + "severity": "recoverable" + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.checkout_session.update.200.json b/Test/Unit/_fixtures/shopping.checkout_session.update.200.json index b090db8..35e1dce 100644 --- a/Test/Unit/_fixtures/shopping.checkout_session.update.200.json +++ b/Test/Unit/_fixtures/shopping.checkout_session.update.200.json @@ -2,23 +2,23 @@ "id": "checkout_session_placeholder_0001", "line_items": [ { - "id": "1001", + "id": "id-d80eae6e", "quantity": 2, "item": { - "id": "24-MB04", - "title": "Strive Shoulder Pack", - "price": 3200, - "image_url": "https://merchant.example/media/catalog/product/cache/IMAGECACHEHASH/m/b/mb04-black-0.jpg" + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, + "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" }, "totals": [ { "type": "subtotal", - "amount": 6400, + "amount": 4000, "display_text": "Subtotal" }, { "type": "total", - "amount": 6400, + "amount": 4000, "display_text": "Total" } ] @@ -31,22 +31,37 @@ "phone_number": "+15555550123" }, "ucp": { - "version": "2026-01-23", + "version": "2026-04-08", "capabilities": { "dev.ucp.shopping.checkout": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specs/shopping/checkout", "schema": "https://ucp.dev/schemas/shopping/checkout.json" } ], "dev.ucp.shopping.discount": [ { - "version": "2026-01-23", + "version": "2026-04-08", "spec": "https://ucp.dev/specification/discount", "schema": "https://ucp.dev/schemas/shopping/discount.json", "extends": "dev.ucp.shopping.checkout" } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } ] }, "payment_handlers": {} @@ -56,7 +71,7 @@ { "type": "subtotal", "display_text": "Subtotal", - "amount": 6400 + "amount": 4000 }, { "type": "fulfillment", @@ -71,7 +86,7 @@ { "type": "total", "display_text": "Grand Total", - "amount": 7400 + "amount": 5000 } ], "links": [], @@ -84,27 +99,27 @@ "id": "shipping", "type": "shipping", "line_item_ids": [ - "83" + "id-d80eae6e" ], "destinations": [ { - "id": "1002", - "street_address": "1 Main St", - "address_locality": "Austin", - "address_region": "Texas", + "id": "dest-1", + "street_address": "1 Infinite Loop", + "address_locality": "Cupertino", + "address_region": "California", "address_country": "US", - "postal_code": "78701", + "postal_code": "95014", "first_name": "Ada", "last_name": "Lovelace", "phone_number": "+15555550123" } ], - "selected_destination_id": "118", + "selected_destination_id": "dest-1", "groups": [ { "id": "package", "line_item_ids": [ - "83" + "id-d80eae6e" ], "options": [ { @@ -140,6 +155,8 @@ } ] }, + "expires_at": "2026-01-01T00:00:00+00:00", + "continue_url": "https://merchant.example/ucp/checkout/resume/id/gyqnDxUCnKrhSBAGEUefgzLNbOP2TiAU", "messages": [], "status": "ready_for_complete" } diff --git a/Test/Unit/_fixtures/shopping.order.get.200.json b/Test/Unit/_fixtures/shopping.order.get.200.json new file mode 100644 index 0000000..b5f099e --- /dev/null +++ b/Test/Unit/_fixtures/shopping.order.get.200.json @@ -0,0 +1,114 @@ +{ + "ucp": { + "version": "2026-04-08", + "status": "success", + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "id": "id-8f6482bf", + "checkout_id": "checkout_session_placeholder_0001", + "permalink_url": "https://merchant.example/sales/order/view/order_id/1000", + "line_items": [ + { + "id": "id-d80eae6e", + "item": { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, + "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + }, + "quantity": { + "original": 2, + "total": 2, + "fulfilled": 0 + }, + "totals": [ + { + "type": "subtotal", + "display_text": "Subtotal", + "amount": 4000 + }, + { + "type": "total", + "display_text": "Total", + "amount": 4000 + } + ], + "status": "processing" + } + ], + "fulfillment": { + "expectations": [ + { + "id": "package", + "line_items": [ + { + "id": "id-d80eae6e", + "quantity": 2 + } + ], + "method_type": "shipping", + "destination": { + "street_address": "1 Infinite Loop", + "address_locality": "Cupertino", + "address_region": "California", + "address_country": "US", + "postal_code": "95014", + "first_name": "Ada", + "last_name": "Lovelace", + "phone_number": "+15555550123" + }, + "description": "Flat Rate - Fixed" + } + ], + "events": [] + }, + "currency": "USD", + "totals": [ + { + "type": "subtotal", + "display_text": "Subtotal", + "amount": 4000 + }, + { + "type": "fulfillment", + "display_text": "Flat Rate - Fixed", + "amount": 1000 + }, + { + "type": "total", + "display_text": "Grand Total", + "amount": 5000 + } + ] +} diff --git a/Test/Unit/_fixtures/shopping.order.get.shipped_refunded.200.json b/Test/Unit/_fixtures/shopping.order.get.shipped_refunded.200.json new file mode 100644 index 0000000..4d0a61d --- /dev/null +++ b/Test/Unit/_fixtures/shopping.order.get.shipped_refunded.200.json @@ -0,0 +1,150 @@ +{ + "ucp": { + "version": "2026-04-08", + "status": "success", + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/checkout", + "schema": "https://ucp.dev/schemas/shopping/checkout.json" + } + ], + "dev.ucp.shopping.discount": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specification/discount", + "schema": "https://ucp.dev/schemas/shopping/discount.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.fulfillment": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/fulfillment", + "schema": "https://ucp.dev/schemas/shopping/fulfillment.json", + "extends": "dev.ucp.shopping.checkout" + } + ], + "dev.ucp.shopping.order": [ + { + "version": "2026-04-08", + "spec": "https://ucp.dev/specs/shopping/order", + "schema": "https://ucp.dev/schemas/shopping/order.json" + } + ] + } + }, + "id": "id-8f6482bf", + "checkout_id": "checkout_session_placeholder_0001", + "permalink_url": "https://merchant.example/sales/order/view/order_id/1000", + "line_items": [ + { + "id": "id-d80eae6e", + "item": { + "id": "ucp-conformance-simple", + "title": "UCP Conformance Simple Product", + "price": 2000, + "image_url": "https://merchant.example/static/versionSTATIC/frontend/Magento/luma/en_US/Magento_Catalog/images/product/placeholder/image.jpg" + }, + "quantity": { + "original": 2, + "total": 1, + "fulfilled": 1 + }, + "totals": [ + { + "type": "subtotal", + "display_text": "Subtotal", + "amount": 4000 + }, + { + "type": "total", + "display_text": "Total", + "amount": 4000 + } + ], + "status": "fulfilled" + } + ], + "fulfillment": { + "expectations": [ + { + "id": "package", + "line_items": [ + { + "id": "id-d80eae6e", + "quantity": 2 + } + ], + "method_type": "shipping", + "destination": { + "street_address": "1 Infinite Loop", + "address_locality": "Cupertino", + "address_region": "California", + "address_country": "US", + "postal_code": "95014", + "first_name": "Ada", + "last_name": "Lovelace", + "phone_number": "+15555550123" + }, + "description": "Flat Rate - Fixed" + } + ], + "events": [ + { + "id": "id-f2a2fd58", + "occurred_at": "2026-01-01T00:00:00+00:00", + "type": "shipped", + "line_items": [ + { + "id": "id-d80eae6e", + "quantity": 2 + } + ], + "tracking_number": "UCPTRACK000000135", + "carrier": "Flat Rate", + "tracking_url": "https://merchant.example/shipping/tracking/popup?hash=nonce-placeholder" + } + ] + }, + "currency": "USD", + "totals": [ + { + "type": "subtotal", + "display_text": "Subtotal", + "amount": 4000 + }, + { + "type": "fulfillment", + "display_text": "Flat Rate - Fixed", + "amount": 1000 + }, + { + "type": "total", + "display_text": "Grand Total", + "amount": 5000 + } + ], + "adjustments": [ + { + "id": "id-348d5283", + "type": "refund", + "occurred_at": "2026-01-01T00:00:00+00:00", + "status": "completed", + "line_items": [ + { + "id": "id-d80eae6e", + "quantity": -1 + } + ], + "totals": [ + { + "type": "total", + "display_text": "Refund", + "amount": -3000 + } + ] + } + ] +} diff --git a/composer.json b/composer.json index abc6513..8000f3e 100644 --- a/composer.json +++ b/composer.json @@ -6,8 +6,9 @@ "require": { "magento/framework": "*", "magebitcom/magento2-core": "0.1.*", - "php": ">=8.1", - "magebitcom/ucp-php-spec": "^2.0" + "php": ">=8.2", + "magebitcom/ucp-php-spec": "^2.1", + "magebitcom/magento2-module-agentic-core": "^0.1" }, "license": [ "MIT" diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 3e5af05..f4f6b05 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -29,6 +29,14 @@ validate-zero-or-greater validate-digits + + + + + Magento\Config\Model\Config\Source\Yesno + Posts order lifecycle events to the URL the agent publishes in its own profile. Each delivery is signed with this store's key, which is published at /.well-known/ucp so the agent can verify it. The key is created automatically on first use. + + diff --git a/etc/config.xml b/etc/config.xml index 4d83136..bffc893 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -9,6 +9,9 @@ 24 + + 1 + checkmo diff --git a/etc/crontab.xml b/etc/crontab.xml index 023e4a7..f4eda29 100644 --- a/etc/crontab.xml +++ b/etc/crontab.xml @@ -13,5 +13,8 @@ 17 * * * * + + * * * * * + diff --git a/etc/db_schema.xml b/etc/db_schema.xml index 1c6cd42..4894864 100644 --- a/etc/db_schema.xml +++ b/etc/db_schema.xml @@ -38,6 +38,7 @@ + @@ -49,4 +50,21 @@ + + + + + + + + + + + + + + + + +
diff --git a/etc/db_schema_whitelist.json b/etc/db_schema_whitelist.json index 72b47e9..5d7be4f 100644 --- a/etc/db_schema_whitelist.json +++ b/etc/db_schema_whitelist.json @@ -26,6 +26,7 @@ "order_id": true, "webhook_url": true, "submitted_fulfillment": true, + "buyer_consent": true, "created_at": true, "updated_at": true }, @@ -35,5 +36,20 @@ "UCP_CHECKOUT_META_QUOTE_ID_QUOTE_ENTITY_ID": true, "UCP_CHECKOUT_META_ORDER_ID_SALES_ORDER_ENTITY_ID": true } + }, + "ucp_order_adjustment": { + "column": { + "entity_id": true, + "order_id": true, + "adjustment_id": true, + "payload": true, + "created_at": true, + "updated_at": true + }, + "constraint": { + "PRIMARY": true, + "UCP_ORDER_ADJUSTMENT_ORDER_ID_ADJUSTMENT_ID": true, + "UCP_ORDER_ADJUSTMENT_ORDER_ID_SALES_ORDER_ENTITY_ID": true + } } -} \ No newline at end of file +} diff --git a/etc/di.xml b/etc/di.xml index 7751f8d..b2a1db6 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -2,10 +2,6 @@ - - - - @@ -19,36 +15,67 @@ + + + + + Placed by an agent over UCP + + + + + + + + + Magebit\UniversalCommerce\Model\Service\Shopping\Capability\CartCapability + Magebit\UniversalCommerce\Model\Service\Shopping\Capability\CatalogLookupCapability + Magebit\UniversalCommerce\Model\Service\Shopping\Capability\CatalogSearchCapability + Magebit\UniversalCommerce\Model\Service\Shopping\Capability\BuyerConsentCapability Magebit\UniversalCommerce\Model\Service\Shopping\Capability\CheckoutCapability Magebit\UniversalCommerce\Model\Service\Shopping\Capability\DiscountCapability + Magebit\UniversalCommerce\Model\Service\Shopping\Capability\FulfillmentCapability + Magebit\UniversalCommerce\Model\Service\Shopping\Capability\OrderCapability - \Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface::TYPE_SUBTOTAL - \Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface::TYPE_DISCOUNT - \Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface::TYPE_ITEMS_DISCOUNT - \Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface::TYPE_DISCOUNT - \Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface::TYPE_FEE - \Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface::TYPE_FULFILLMENT - \Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface::TYPE_TAX - \Magebit\UcpSpec\Api\Shopping\Types\TotalResponseInterface::TYPE_TOTAL + \Magebit\UniversalCommerce\Api\Data\TotalTypeInterface::TYPE_SUBTOTAL + \Magebit\UniversalCommerce\Api\Data\TotalTypeInterface::TYPE_DISCOUNT + \Magebit\UniversalCommerce\Api\Data\TotalTypeInterface::TYPE_ITEMS_DISCOUNT + \Magebit\UniversalCommerce\Api\Data\TotalTypeInterface::TYPE_DISCOUNT + \Magebit\UniversalCommerce\Api\Data\TotalTypeInterface::TYPE_FEE + \Magebit\UniversalCommerce\Api\Data\TotalTypeInterface::TYPE_FULFILLMENT + \Magebit\UniversalCommerce\Api\Data\TotalTypeInterface::TYPE_TAX + \Magebit\UniversalCommerce\Api\Data\TotalTypeInterface::TYPE_TOTAL + + + + + dev.ucp.shopping.ap2_mandate + + + + Magebit\UniversalCommerce\Model\Service\Shopping\Validation\QuoteAddressValidator + Magebit\UniversalCommerce\Model\Service\Shopping\Validation\QuoteFulfillmentValidator Magebit\UniversalCommerce\Model\Service\Shopping\CheckoutDataProcessor @@ -57,12 +84,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Magebit\UniversalCommerce\Model\Webhook\DeliveryHeadersProvider + + + + + + Magebit\UniversalCommerce\Model\Webhook\Dispatcher + + + + + + Magebit\UniversalCommerce\Model\Webhook\Dispatcher + + @@ -79,7 +162,9 @@ - + + + @@ -92,6 +177,9 @@ + + + @@ -110,4 +198,23 @@ + + + + Magebit\UniversalCommerce\Console\Command\SeedConformance + Magebit\UniversalCommerce\Console\Command\DispatchWebhooks + + + + + + Magebit\UniversalCommerce\Model\Webhook\DueDeliveries + + + + + Magebit\UniversalCommerce\Model\Webhook\Dispatcher + ucp + + diff --git a/etc/events.xml b/etc/events.xml new file mode 100644 index 0000000..8b9f251 --- /dev/null +++ b/etc/events.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/etc/frontend/di.xml b/etc/frontend/di.xml index 8396bd6..aa60295 100644 --- a/etc/frontend/di.xml +++ b/etc/frontend/di.xml @@ -21,7 +21,7 @@ - + @@ -60,7 +60,75 @@ service shopping_complete + + ucp/shopping/carts + POST + service + shopping_cart_create + + + ucp/shopping/carts/{cart_id} + GET + service + shopping_cart_get + + + ucp/shopping/carts/{cart_id} + PUT + service + shopping_cart_update + + + ucp/shopping/carts/{cart_id}/cancel + POST + service + shopping_cart_cancel + + + ucp/shopping/catalog/search + POST + service + shopping_catalog_search + + + ucp/shopping/catalog/lookup + POST + service + shopping_catalog_lookup + + + ucp/shopping/catalog/product + POST + service + shopping_catalog_product + + + ucp/shopping/orders/{order_id} + GET + service + shopping_getorder + + + ucp/shopping/orders/{order_id} + PUT + service + shopping_updateorder + + + + testing/simulate-shipping/{order_id} + POST + testing + simulateshipping + - + + + + + Magebit\UniversalCommerce\Controller\RouteProvider + ucp + + diff --git a/etc/module.xml b/etc/module.xml index 53fcf9e..4743f62 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -9,5 +9,9 @@ */ --> - + + + + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3bd9453..2c43cfc 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,11 +1,5 @@ parameters: ignoreErrors: - - - message: '#^Instanceof between Magento\\Framework\\App\\Request\\Http and Magento\\Framework\\App\\Request\\Http will always evaluate to true\.$#' - identifier: instanceof.alwaysTrue - count: 1 - path: Controller/ApiController.php - - message: '#^Cannot cast mixed to int\.$#' identifier: cast.int @@ -36,54 +30,6 @@ parameters: count: 1 path: Model/CheckoutMeta/Model.php - - - message: '#^Cannot cast mixed to int\.$#' - identifier: cast.int - count: 2 - path: Model/IdempotencyKey/Model.php - - - - message: '#^Method Magebit\\UniversalCommerce\\Model\\IdempotencyKey\\Model\:\:getCreatedAt\(\) should return string\|null but returns mixed\.$#' - identifier: return.type - count: 1 - path: Model/IdempotencyKey/Model.php - - - - message: '#^Method Magebit\\UniversalCommerce\\Model\\IdempotencyKey\\Model\:\:getKey\(\) should return string\|null but returns mixed\.$#' - identifier: return.type - count: 1 - path: Model/IdempotencyKey/Model.php - - - - message: '#^Method Magebit\\UniversalCommerce\\Model\\IdempotencyKey\\Model\:\:getRequestHash\(\) should return string\|null but returns mixed\.$#' - identifier: return.type - count: 1 - path: Model/IdempotencyKey/Model.php - - - - message: '#^Method Magebit\\UniversalCommerce\\Model\\IdempotencyKey\\Model\:\:getResponseBody\(\) should return string\|null but returns mixed\.$#' - identifier: return.type - count: 1 - path: Model/IdempotencyKey/Model.php - - - - message: '#^Method Magebit\\UniversalCommerce\\Model\\IdempotencyKey\\Model\:\:getUpdatedAt\(\) should return string\|null but returns mixed\.$#' - identifier: return.type - count: 1 - path: Model/IdempotencyKey/Model.php - - - - message: '#^Parameter \#2 \$data of method Magebit\\UniversalCommerce\\Model\\RequestClassBuilder\:\:populateWithArray\(\) expects array\, mixed given\.$#' - identifier: argument.type - count: 1 - path: Model/RequestClassBuilder.php - - - - message: '#^Cannot access offset ''capabilities'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: Model/Service/Shopping/AgentProfileParser.php - - message: '#^Cannot access offset ''webhook_url'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -102,12 +48,6 @@ parameters: count: 1 path: Model/Service/Shopping/AgentProfileParser.php - - - message: '#^Method Magebit\\UniversalCommerce\\Model\\Service\\Shopping\\AgentProfileParser\:\:extractWebhookUrl\(\) should return string\|null but returns mixed\.$#' - identifier: return.type - count: 1 - path: Model/Service/Shopping/AgentProfileParser.php - - message: '#^Method Magebit\\UniversalCommerce\\Model\\Service\\Shopping\\AgentProfileParser\:\:fetchHttpProfile\(\) should return array\\|null but returns array\\|null\.$#' identifier: return.type @@ -138,12 +78,6 @@ parameters: count: 1 path: Model/Service/Shopping/AgentProfileParser.php - - - message: '#^Negated boolean expression is always false\.$#' - identifier: booleanNot.alwaysFalse - count: 1 - path: Model/Service/Shopping/CheckoutDataProcessor.php - - message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' identifier: function.alreadyNarrowedType @@ -168,14 +102,3 @@ parameters: count: 1 path: Model/Service/Shopping/Converter/QuoteToFulfillmentResponse.php - - - message: '#^Parameter \#1 \$objectOrClass of class ReflectionClass constructor expects class\-string\\|T of object, string given\.$#' - identifier: argument.type - count: 1 - path: Model/Validation/RequestValidator.php - - - - message: '#^Parameter \#1 \$string of function strtolower expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: Model/Validation/RequestValidator.php