Commit 7c3a4c320b4 for woocommerce
commit 7c3a4c320b4cc95b705523fc47b808d417233475
Author: timur987 <115007291+timur987@users.noreply.github.com>
Date: Fri Oct 30 04:14:51 2026 +0300
Add UCP Cart endpoints (#69724)
Adds the UCP (Universal Commerce Protocol) Cart endpoints to WooCommerce Core. Under the hood they are new Store API routes under the `wc/ucp/v1` namespace:
- `POST /wc/ucp/v1/carts` for creating a cart,
- `GET /wc/ucp/v1/carts/:id` for reading a cart,
- `PUT /wc/ucp/v1/carts/:id` for replacing cart's line items,
- `/wc/ucp/v1/carts/:id/cancel` for marking a cart as canceled.
---------
Co-authored-by: Timur Gogolev <timur.gogolev@automattic.com>
Co-authored-by: sergeymitr <sergeymitr@gmail.com>
diff --git a/plugins/woocommerce/changelog/add-ucp-cart-routes b/plugins/woocommerce/changelog/add-ucp-cart-routes
new file mode 100644
index 00000000000..75b732e1166
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ucp-cart-routes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add opt-in UCP cart endpoints so AI shopping agents can create, read, update and cancel a cart.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 23bb45c8336..038321c9860 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -673,7 +673,7 @@ final class WooCommerce {
}
/**
- * Returns true if the request is a store REST API request.
+ * Returns true if the request is a store REST API or UCP API request.
*
* @return bool
*/
@@ -682,13 +682,19 @@ final class WooCommerce {
return false;
}
- // Pretty permalinks: the Store API namespace is part of the path, e.g. /wp-json/wc/store/v1/cart. Match the
+ $namespaces = array( 'wc/store/', 'wc/ucp/' );
+
+ // Pretty permalinks: the namespace is part of the path, e.g. /wp-json/wc/store/v1/cart. Match the
// path only (a leading slash anchors the prefix) so a REST-like query argument such as
// /some-page/?arg=/wp-json/wc/store/ is not mistaken for a Store API request.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$path = wp_parse_url( '/' . ltrim( (string) wp_unslash( $_SERVER['REQUEST_URI'] ), '/' ), PHP_URL_PATH );
- if ( is_string( $path ) && false !== strpos( $path, '/' . trailingslashit( rest_get_url_prefix() ) . 'wc/store/' ) ) {
- return true;
+ if ( is_string( $path ) ) {
+ foreach ( $namespaces as $namespace ) {
+ if ( false !== strpos( $path, '/' . trailingslashit( rest_get_url_prefix() ) . $namespace ) ) {
+ return true;
+ }
+ }
}
// Plain permalinks: the route is passed as a query parameter, e.g. ?rest_route=/wc/store/v1/cart.
@@ -696,8 +702,10 @@ final class WooCommerce {
if ( isset( $_GET['rest_route'] ) && is_string( $_GET['rest_route'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the route only, no state change.
$rest_route = '/' . ltrim( rawurldecode( sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ) ), '/' );
- if ( 0 === strpos( $rest_route, '/wc/store/' ) ) {
- return true;
+ foreach ( $namespaces as $namespace ) {
+ if ( 0 === strpos( $rest_route, '/' . $namespace ) ) {
+ return true;
+ }
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
new file mode 100644
index 00000000000..70a613ece41
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCartRoute.php
@@ -0,0 +1,537 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute as StoreApiCartRoute;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+use Automattic\WooCommerce\StoreApi\SessionHandler;
+use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpCartMapper;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * Shared behaviour of the UCP cart routes.
+ *
+ * Inherits session binding, cart loading and draft order sync from the Store API cart
+ * route. Adds the UCP envelope, and keys the session off the path instead of a header.
+ *
+ * Writes are last-wins, as for every Store API cart: the session row is read when the
+ * request starts and written whole at shutdown, with no lock or version check. Two
+ * callers updating the same cart id at once can overwrite each other's lines.
+ */
+abstract class AbstractCartRoute extends StoreApiCartRoute {
+ /**
+ * The routes schema.
+ *
+ * @var string
+ */
+ const SCHEMA_TYPE = 'ucp-cart';
+
+ /**
+ * Session key marking a cart that was cancelled through UCP.
+ *
+ * Without it a cancelled cart is indistinguishable from an empty one. Read by the UCP
+ * routes only, so a storefront session cloned from the cart keeps working.
+ */
+ const CANCELLED_SESSION_KEY = 'ucp_cart_cancelled';
+
+ /**
+ * Most lines one request may name.
+ *
+ * Each line can cost a product lookup, so an open route needs a ceiling. Matches the
+ * catalog search page size, so one page of results always fits in one cart request.
+ */
+ const LINE_ITEMS_MAX = 100;
+
+ /**
+ * Build the `ucp` envelope for a response from this route.
+ *
+ * @param string $status Response status, `success` or `error`.
+ * @return array
+ */
+ protected function ucp_metadata( string $status ): array {
+ return UcpUtils::response_metadata( UcpUtils::CAPABILITY_CART, $status );
+ }
+
+ /**
+ * Refuse an id this store never signed, and restore the token slot afterwards.
+ *
+ * The slot is per-process, so leaving it set would bind the next dispatch in the same
+ * process — an internal `rest_do_request()`, or a test — to this cart's session.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ public function get_response( \WP_REST_Request $request ) {
+ if ( '' !== $this->route_cart_id( $request ) && '' === $this->validated_cart_id( $request ) ) {
+ return $this->add_response_headers( $this->ucp_not_found() );
+ }
+
+ $inherited_token = CartTokenUtils::get_request_cart_token();
+
+ try {
+ return parent::get_response( $request );
+ } finally {
+ $this->restore_request_cart_token( $inherited_token );
+ remove_filter( 'woocommerce_session_handler', array( self::class, 'session_handler_class' ) );
+ }
+ }
+
+ /**
+ * Put the request's cart token slot back the way it was found.
+ *
+ * @param string $token The token the request arrived with, or '' for none.
+ */
+ private function restore_request_cart_token( string $token ): void {
+ if ( '' === $token ) {
+ // phpcs:ignore WooCommerceStoreApi.StoreApi.CartTokenSource.ServerSuperglobal -- Clearing the slot, not reading the token from it.
+ unset( $_SERVER['HTTP_CART_TOKEN'] );
+ return;
+ }
+
+ // phpcs:ignore WooCommerceStoreApi.StoreApi.CartTokenSource.ServerSuperglobalWrite -- Restoring the value the request arrived with, already validated by whatever read it.
+ $_SERVER['HTTP_CART_TOKEN'] = $token;
+ }
+
+ /**
+ * Bind the session to the cart id in the route path.
+ *
+ * The parent picks the Store API session handler from a `Cart-Token` header. UCP
+ * carries that token in the path, so it is promoted to the header first.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return void
+ */
+ protected function load_cart_session( \WP_REST_Request $request ) {
+ $cart_id = $this->validated_cart_id( $request );
+
+ if ( '' === $cart_id ) {
+ // Creation starts from a fresh guest session: the id is minted here, never
+ // inherited from a Cart-Token header or from the logged-in user. Without this the
+ // token handler would key an empty session on the user id and overwrite the
+ // shopper's own session row on save.
+ $cart_id = CartTokenUtils::get_cart_token( wc_rand_hash( 't_', 30 ) );
+ }
+
+ $request->set_header( 'Cart-Token', $cart_id );
+ // phpcs:ignore WooCommerceStoreApi.StoreApi.CartTokenSource.ServerSuperglobalWrite -- Either the route's own path segment, signature checked in validated_cart_id(), or a token minted just above; promoted to the slot PHP would have filled from a Cart-Token header.
+ $_SERVER['HTTP_CART_TOKEN'] = $cart_id;
+
+ // The parent promotes the token handler only for a request that carries a token,
+ // which would leave cart creation on the cookie handler. UCP has no cookie to
+ // trust: the id is the only credential, so the handler is fixed for every route.
+ add_filter( 'woocommerce_session_handler', array( self::class, 'session_handler_class' ) );
+
+ // An extension that calls wc_load_cart() on init leaves a session already in memory, so
+ // load_cart() below keeps it and the filter above never binds: the request would act on
+ // that cookie or guest session instead of the id it named. Drop it unless it already is
+ // the token handler for this id, so a legitimate request keeps its own loaded cart.
+ $session = WC()->session;
+ $expected = CartTokenUtils::get_cart_token_payload( $cart_id )['user_id'];
+ if ( ! $session instanceof SessionHandler || (string) $session->get_customer_id() !== (string) $expected ) {
+ $this->discard_preloaded_cart_session();
+ }
+
+ parent::load_cart_session( $request );
+ }
+
+ /**
+ * Reset to the cart state a REST request starts from, before anything called wc_load_cart().
+ *
+ * Clearing the session, cart, customer and the one-time load marker lets load_cart()
+ * rebuild through the forced token handler, whatever ran earlier in the request.
+ */
+ private function discard_preloaded_cart_session(): void {
+ WC()->session = null; // @phpstan-ignore assign.propertyType (Defaults to null; reset so load_cart() rebuilds it.)
+ WC()->customer = null; // @phpstan-ignore assign.propertyType (Same.)
+ WC()->cart = null; // @phpstan-ignore assign.propertyType (Same.)
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Clears the per-process marker so load_cart() reloads.
+ unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+ }
+
+ /**
+ * The session handler a UCP cart route binds to.
+ *
+ * Token-keyed, so an id this store signed is the only way to reach a cart. Without a
+ * valid token it mints an empty session, which is what cart creation needs.
+ *
+ * @return string
+ */
+ public static function session_handler_class(): string {
+ return SessionHandler::class;
+ }
+
+ /**
+ * The request's cart id when its signature checks out, or '' when it does not.
+ *
+ * Read from the request every time: the route instance outlives a request, so a
+ * cached id would bind the next dispatch in the same process to this cart.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return string
+ */
+ protected function validated_cart_id( \WP_REST_Request $request ): string {
+ $cart_id = $this->route_cart_id( $request );
+
+ return CartTokenUtils::validate_cart_token( $cart_id ) ? $cart_id : '';
+ }
+
+ /**
+ * Dispatch the request, refusing a cart that was cancelled.
+ *
+ * Runs after the session is bound, so one check covers every id-bearing route.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response|\WP_Error
+ */
+ protected function get_response_by_request_method( \WP_REST_Request $request ) {
+ if ( '' !== $this->route_cart_id( $request ) && $this->cart_is_cancelled() ) {
+ return $this->ucp_not_found();
+ }
+
+ return parent::get_response_by_request_method( $request );
+ }
+
+ /**
+ * UCP requests are not browser requests, so they carry no nonce.
+ *
+ * Safe because `load_cart_session()` binds every route to the token-keyed session
+ * handler, so no request here reads the browser's session cookie: there is no ambient
+ * credential for a cross-site request to ride on. Cart creation is left open
+ * deliberately — it mints a guest session of its own, whoever is logged in.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return bool
+ */
+ protected function requires_nonce( \WP_REST_Request $request ) {
+ return false;
+ }
+
+ /**
+ * Read the cart id from the route path.
+ *
+ * `get_url_params()` only: `get_param()` merges body and query over it, which would
+ * let a caller name a session the route never handed them.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return string The path-supplied id, or '' when the route has no `id` segment.
+ */
+ protected function route_cart_id( \WP_REST_Request $request ): string {
+ $params = $request->get_url_params();
+
+ return isset( $params['id'] ) && is_scalar( $params['id'] ) ? (string) $params['id'] : '';
+ }
+
+ /**
+ * Mint the cart id for the session the request is bound to.
+ *
+ * @return string
+ */
+ protected function current_cart_id(): string {
+ $session = WC()->session;
+
+ return $session ? CartTokenUtils::get_cart_token( (string) $session->get_customer_id() ) : '';
+ }
+
+ /**
+ * Whether the bound session's cart was cancelled through UCP.
+ *
+ * @return bool
+ */
+ protected function cart_is_cancelled(): bool {
+ $session = WC()->session;
+
+ return $session ? (bool) $session->get( self::CANCELLED_SESSION_KEY ) : false;
+ }
+
+ /**
+ * Build a successful cart response.
+ *
+ * @param string $cart_id Cart identifier.
+ * @param array<string, mixed> $context Optional `messages` to merge into the payload.
+ * @return \WP_REST_Response
+ */
+ protected function cart_response( string $cart_id, array $context = array() ): \WP_REST_Response {
+ return new \WP_REST_Response(
+ array_merge(
+ array( 'ucp' => $this->ucp_metadata( 'success' ) ),
+ ( new UcpCartMapper() )->build_cart( $this->cart_item_response(), $cart_id, $context )
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Build the Store API cart response for the current cart, as nested arrays.
+ *
+ * @return array
+ */
+ private function cart_item_response(): array {
+ // Nested values are stdClass; decode through JSON for uniform array access.
+ return (array) json_decode( (string) wp_json_encode( $this->schema->get_item_response( $this->cart_controller->get_cart_for_response() ) ), true );
+ }
+
+ /**
+ * Build an error response.
+ *
+ * `$status` follows the split UCP draws: a malformed request is a transport error and
+ * keeps a non-2xx status, while a valid request that ends in a refusal is a business
+ * outcome and is reported at 200 through `ucp.status` and `messages`.
+ *
+ * @param string $code Error code.
+ * @param string $message Error message.
+ * @param string $severity Error severity.
+ * @param int $status HTTP status.
+ * @param string $path JSONPath of the offending field.
+ * @return \WP_REST_Response
+ */
+ protected function ucp_error( string $code, string $message, string $severity, int $status, string $path = '$' ): \WP_REST_Response {
+ return UcpUtils::error_response( UcpUtils::CAPABILITY_CART, $code, $message, $severity, $status, $path );
+ }
+
+ /**
+ * Report an error the parent raised in the UCP envelope.
+ *
+ * Every failure the Store API base class produces — a session that fails to load,
+ * a `RouteException` a handler let through, an unexpected exception — funnels through
+ * here. The cart's own code is kept where UCP has a name for it, and the HTTP status
+ * is the parent's. The `cart` payload the parent attaches to a 409 conflict is dropped:
+ * the UCP envelope has no slot for it, and the client re-reads the cart instead.
+ *
+ * @param \WP_Error $error WP Error object.
+ * @return \WP_REST_Response
+ */
+ protected function error_to_response( $error ) {
+ $data = $error->get_error_data();
+ $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500;
+ $message = (string) $error->get_error_message();
+
+ return $this->ucp_error(
+ UcpUtils::cart_error_code( (string) $error->get_error_code(), 'TRANSPORT_ERROR' ),
+ '' !== $message ? $message : __( 'The cart could not be loaded. Please try again.', 'woocommerce' ),
+ $status >= 500 ? 'unrecoverable' : 'recoverable',
+ $status
+ );
+ }
+
+ /**
+ * Build the response for a cart id that does not resolve.
+ *
+ * Never existed, expired and cancelled are reported alike, and at 200: the request was
+ * valid, so the refusal is a business outcome. See `ucp_error()`.
+ *
+ * @return \WP_REST_Response
+ */
+ protected function ucp_not_found(): \WP_REST_Response {
+ return UcpUtils::error_response( UcpUtils::CAPABILITY_CART, 'not_found', __( 'Cart not found.', 'woocommerce' ), 'unrecoverable', 200, '$.id' );
+ }
+
+ /**
+ * Parse the `line_items` of a request body into desired quantities.
+ *
+ * Two lines can hold the same product and differ only in cart item data, as an add-on
+ * does, so a payload naming a line is collected per cart item. A line id the cart no
+ * longer holds falls back to `item.id`: the buyer may have removed it meanwhile.
+ *
+ * A malformed payload is refused at 400, an unsellable id at 200 — see `ucp_error()`.
+ *
+ * @param mixed $line_items Raw `line_items` value.
+ * @return array{by_key: array<string, int>, by_product: array<int, int>}|\WP_REST_Response
+ * Quantities per cart item key and per product id, or an error response.
+ */
+ protected function parse_line_items( $line_items ) {
+ if ( ! is_array( $line_items ) ) {
+ return $this->ucp_error( 'VALIDATION_ERROR', __( 'line_items must be an array.', 'woocommerce' ), 'recoverable', 400, '$.line_items' );
+ }
+
+ if ( count( $line_items ) > self::LINE_ITEMS_MAX ) {
+ return $this->ucp_error(
+ 'VALIDATION_ERROR',
+ /* translators: %d: maximum number of line items accepted in one request. */
+ sprintf( __( 'line_items may hold at most %d entries.', 'woocommerce' ), self::LINE_ITEMS_MAX ),
+ 'recoverable',
+ 400,
+ '$.line_items'
+ );
+ }
+
+ $cart_items = $this->cart_controller->get_cart_items();
+ $desired = array(
+ 'by_key' => array(),
+ 'by_product' => array(),
+ );
+
+ foreach ( $line_items as $index => $line_item ) {
+ $path = sprintf( '$.line_items[%d]', (int) $index );
+
+ if ( ! is_array( $line_item ) || ! isset( $line_item['item']['id'] ) || ! is_scalar( $line_item['item']['id'] ) ) {
+ return $this->ucp_error( 'VALIDATION_ERROR', __( 'Each line item requires item.id.', 'woocommerce' ), 'recoverable', 400, $path . '.item.id' );
+ }
+
+ $quantity = isset( $line_item['quantity'] ) ? (int) $line_item['quantity'] : 1;
+
+ if ( $quantity < 1 ) {
+ return $this->ucp_error( 'VALIDATION_ERROR', __( 'Line item quantity must be at least 1.', 'woocommerce' ), 'recoverable', 400, $path . '.quantity' );
+ }
+
+ $item_key = isset( $line_item['id'] ) && is_scalar( $line_item['id'] ) ? (string) $line_item['id'] : '';
+
+ // A named line is addressed directly, so the product behind it is not
+ // re-resolved: one that has since gone out of stock must still be adjustable.
+ if ( '' !== $item_key && isset( $cart_items[ $item_key ] ) ) {
+ $desired['by_key'][ $item_key ] = ( $desired['by_key'][ $item_key ] ?? 0 ) + $quantity;
+ continue;
+ }
+
+ $item_id = (string) $line_item['item']['id'];
+ $product = $this->get_item_product( $item_id );
+
+ // The payload is well formed; the store simply has nothing orderable under that
+ // id. A business outcome, so 200 like every other refusal. An id matching no
+ // product is `not_found`; one matching a product a cart line cannot hold — a
+ // variable parent, grouped, external or otherwise unpurchasable — exists but is
+ // unavailable.
+ if ( ! $product instanceof \WC_Product ) {
+ return $this->ucp_error( 'not_found', __( 'Product not found.', 'woocommerce' ), 'unrecoverable', 200, $path . '.item.id' );
+ }
+
+ if ( $product instanceof \WC_Product_Variable || ! $product->is_purchasable() ) {
+ $message = $product instanceof \WC_Product_Variable
+ ? __( 'This product is only orderable as one of its variants. Use a variant id from the catalog.', 'woocommerce' )
+ : __( 'This product cannot be added to a cart.', 'woocommerce' );
+
+ return $this->ucp_error( 'item_unavailable', $message, 'recoverable', 200, $path . '.item.id' );
+ }
+
+ // Repeating an id is additive, matching how a cart treats two adds of the
+ // same product.
+ $product_id = (int) $product->get_id();
+ $desired['by_product'][ $product_id ] = ( $desired['by_product'][ $product_id ] ?? 0 ) + $quantity;
+ }
+
+ return $desired;
+ }
+
+ /**
+ * Load the product an item id names.
+ *
+ * Ids only, as the catalog routes resolve them: the catalog hands out no SKU
+ * identifier, and a SKU fallback would let a numeric SKU resolve as a post id.
+ *
+ * @param string $item_id Item identifier.
+ * @return \WC_Product|null
+ */
+ private function get_item_product( string $item_id ) {
+ $product_id = ctype_digit( $item_id ) ? (int) $item_id : 0;
+ $product = $product_id > 0 ? wc_get_product( $product_id ) : null;
+
+ return $product instanceof \WC_Product ? $product : null;
+ }
+
+ /**
+ * Bring the cart in line with a set of desired quantities.
+ *
+ * `line_items` is the complete desired state, so an unaccounted-for line is removed.
+ * An entry naming a cart item addresses that one; an entry naming only a product takes
+ * the first line holding it, and further lines of that product go.
+ *
+ * A refusal from the cart is returned in the UCP envelope, carrying the cart's own
+ * message, since the Store API error shape has no `ucp` object to read. Removals run
+ * before adds, so the contents are restored first: otherwise replacing a cart with
+ * something out of stock would empty it.
+ *
+ * @param array{by_key: array<string, int>, by_product: array<int, int>} $desired Desired quantities.
+ * @return \WP_REST_Response|null An error response, or null when the cart was updated.
+ * @throws \Throwable Anything other than a cart refusal, after the cart is restored.
+ */
+ protected function apply_line_items( array $desired ) {
+ $cart = $this->cart_controller->get_cart_instance();
+ $snapshot = $cart->get_cart_contents();
+
+ try {
+ $this->reconcile_line_items( $desired );
+ } catch ( \Throwable $error ) {
+ // Whatever threw, including a hook inside the cart, the removals already
+ // applied must not stand.
+ $cart->set_cart_contents( $snapshot );
+ $this->cart_controller->calculate_totals();
+
+ if ( ! $error instanceof RouteException ) {
+ throw $error;
+ }
+
+ return $this->ucp_error(
+ UcpUtils::cart_error_code( $error->getErrorCode() ),
+ $error->getMessage(),
+ 'recoverable',
+ 200,
+ '$.line_items'
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Reconcile the cart to the desired quantities.
+ *
+ * @param array{by_key: array<string, int>, by_product: array<int, int>} $desired Desired quantities.
+ * @throws RouteException When an item cannot be added or adjusted.
+ */
+ private function reconcile_line_items( array $desired ): void {
+ $by_key = $desired['by_key'];
+ $by_product = $desired['by_product'];
+
+ foreach ( $this->cart_controller->get_cart_items() as $key => $cart_item ) {
+ $key = (string) $key;
+ $product_id = (int) ( $cart_item['variation_id'] ? $cart_item['variation_id'] : $cart_item['product_id'] );
+ $quantity = (int) $cart_item['quantity'];
+
+ if ( isset( $by_key[ $key ] ) ) {
+ $target = $by_key[ $key ];
+ } elseif ( isset( $by_product[ $product_id ] ) ) {
+ $target = $by_product[ $product_id ];
+ // Claimed, so a second line of the same product is not kept as a duplicate.
+ unset( $by_product[ $product_id ] );
+ } else {
+ $this->cart_controller->get_cart_instance()->remove_cart_item( $key );
+ continue;
+ }
+
+ if ( $target !== $quantity ) {
+ // Totals are recalculated once below, not per changed line.
+ $this->cart_controller->set_cart_item_quantity( $key, $target, false );
+ }
+ }
+
+ foreach ( $by_product as $product_id => $quantity ) {
+ $this->cart_controller->add_to_cart(
+ array(
+ 'id' => $product_id,
+ 'quantity' => $quantity,
+ )
+ );
+ }
+
+ $this->cart_controller->calculate_totals();
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
index 211e8b58a21..7f6769625b0 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
@@ -139,22 +139,7 @@ abstract class AbstractCatalogRoute extends AbstractRoute {
* @return \WP_REST_Response
*/
protected function ucp_error( string $code, string $message, string $severity, int $status ): \WP_REST_Response {
- return new \WP_REST_Response(
- array(
- 'ucp' => $this->ucp_metadata( 'error' ),
- 'messages' => array(
- array(
- 'type' => 'error',
- 'code' => $code,
- 'content' => $message,
- 'severity' => $severity,
- 'path' => '$',
- 'content_type' => 'plain',
- ),
- ),
- ),
- $status
- );
+ return UcpUtils::error_response( $this->get_capability(), $code, $message, $severity, $status );
}
/**
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php
new file mode 100644
index 00000000000..9756888432f
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/Carts.php
@@ -0,0 +1,99 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+/**
+ * UCP `POST /carts` route.
+ */
+class Carts extends AbstractCartRoute {
+ /**
+ * The route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-carts';
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path of this rest route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/carts';
+ }
+
+ /**
+ * Get method arguments for this REST route.
+ *
+ * @return array An array of endpoints.
+ */
+ public function get_args() {
+ return array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'line_items' => array(
+ 'description' => __( 'Items to put in the new cart.', 'woocommerce' ),
+ 'type' => 'array',
+ ),
+ ),
+ ),
+ 'schema' => array( $this->schema, 'get_public_item_schema' ),
+ );
+ }
+
+ /**
+ * Create a cart.
+ *
+ * The id is minted from the session the cart was loaded into, so it is the token
+ * that addresses this cart from now on.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_post_response( \WP_REST_Request $request ) {
+ $line_items = $request->get_param( 'line_items' );
+
+ if ( null !== $line_items ) {
+ $desired = $this->parse_line_items( $line_items );
+
+ if ( $desired instanceof \WP_REST_Response ) {
+ return $desired;
+ }
+
+ $error = $this->apply_line_items( $desired );
+
+ if ( null !== $error ) {
+ return $error;
+ }
+ }
+
+ $cart_id = $this->current_cart_id();
+
+ if ( '' === $cart_id ) {
+ return $this->ucp_error(
+ 'TRANSPORT_ERROR',
+ __( 'The cart session could not be created.', 'woocommerce' ),
+ 'recoverable',
+ 500
+ );
+ }
+
+ return $this->cart_response( $cart_id );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php
new file mode 100644
index 00000000000..14e44025271
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsById.php
@@ -0,0 +1,109 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+/**
+ * UCP `GET /carts/{id}` and `PUT /carts/{id}` route.
+ */
+class CartsById extends AbstractCartRoute {
+ /**
+ * The route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-carts-by-id';
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path of this rest route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/carts/(?P<id>[\w\-\.]+)';
+ }
+
+ /**
+ * Get method arguments for this REST route.
+ *
+ * PUT only, as the cart REST binding specifies. PATCH is deliberately not accepted:
+ * `line_items` is the complete desired state, so a partial payload would delete
+ * every line it left out.
+ *
+ * @return array An array of endpoints.
+ */
+ public function get_args() {
+ return array(
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => '__return_true',
+ ),
+ array(
+ 'methods' => 'PUT',
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => '__return_true',
+ 'args' => array(
+ 'line_items' => array(
+ 'description' => __( 'The complete desired contents of the cart.', 'woocommerce' ),
+ 'type' => 'array',
+ ),
+ ),
+ ),
+ 'schema' => array( $this->schema, 'get_public_item_schema' ),
+ );
+ }
+
+ /**
+ * Read a cart.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_response( \WP_REST_Request $request ) {
+ return $this->cart_response( $this->route_cart_id( $request ) );
+ }
+
+ /**
+ * Replace the contents of a cart.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_update_response( \WP_REST_Request $request ) {
+ $line_items = $request->get_param( 'line_items' );
+
+ // `line_items` is the whole desired state, so omitting it is not the same as
+ // sending an empty list: it leaves the cart alone.
+ if ( null !== $line_items ) {
+ $desired = $this->parse_line_items( $line_items );
+
+ if ( $desired instanceof \WP_REST_Response ) {
+ return $desired;
+ }
+
+ $error = $this->apply_line_items( $desired );
+
+ if ( null !== $error ) {
+ return $error;
+ }
+ }
+
+ return $this->cart_response( $this->route_cart_id( $request ) );
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsCancel.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsCancel.php
new file mode 100644
index 00000000000..0a6a4f982d5
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CartsCancel.php
@@ -0,0 +1,86 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+/**
+ * UCP `POST /carts/{id}/cancel` route.
+ */
+class CartsCancel extends AbstractCartRoute {
+ /**
+ * The route identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-carts-cancel';
+
+ /**
+ * Get the path of this REST route.
+ *
+ * @return string
+ */
+ public function get_path() {
+ return self::get_path_regex();
+ }
+
+ /**
+ * Get the path of this rest route.
+ *
+ * @return string
+ */
+ public static function get_path_regex() {
+ return '/carts/(?P<id>[\w\-\.]+)/cancel';
+ }
+
+ /**
+ * Get method arguments for this REST route.
+ *
+ * @return array An array of endpoints.
+ */
+ public function get_args() {
+ return array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'get_response' ),
+ 'permission_callback' => '__return_true',
+ ),
+ 'schema' => array( $this->schema, 'get_public_item_schema' ),
+ );
+ }
+
+ /**
+ * Cancel a cart.
+ *
+ * The response carries the cart as it stood before the cancellation, which the
+ * spec requires; the cart itself is emptied and the id stops resolving.
+ *
+ * @param \WP_REST_Request $request Request object.
+ *
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ *
+ * @return \WP_REST_Response
+ */
+ protected function get_route_post_response( \WP_REST_Request $request ) {
+ $cart_id = $this->route_cart_id( $request );
+
+ $response = $this->cart_response(
+ $cart_id,
+ array(
+ 'messages' => array(
+ array(
+ 'type' => 'info',
+ 'code' => 'CANCELED',
+ 'content' => __( 'Cart canceled.', 'woocommerce' ),
+ 'path' => '$',
+ 'content_type' => 'plain',
+ ),
+ ),
+ )
+ );
+
+ $this->cart_controller->empty_cart();
+ WC()->session->set( self::CANCELLED_SESSION_KEY, true );
+
+ return $response;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/RoutesController.php b/plugins/woocommerce/src/StoreApi/RoutesController.php
index bd3dda1a49e..8a57702430f 100644
--- a/plugins/woocommerce/src/StoreApi/RoutesController.php
+++ b/plugins/woocommerce/src/StoreApi/RoutesController.php
@@ -88,6 +88,9 @@ class RoutesController {
Routes\V1\Ucp\CatalogSearch::IDENTIFIER => Routes\V1\Ucp\CatalogSearch::class,
Routes\V1\Ucp\CatalogLookup::IDENTIFIER => Routes\V1\Ucp\CatalogLookup::class,
Routes\V1\Ucp\CatalogProduct::IDENTIFIER => Routes\V1\Ucp\CatalogProduct::class,
+ Routes\V1\Ucp\Carts::IDENTIFIER => Routes\V1\Ucp\Carts::class,
+ Routes\V1\Ucp\CartsById::IDENTIFIER => Routes\V1\Ucp\CartsById::class,
+ Routes\V1\Ucp\CartsCancel::IDENTIFIER => Routes\V1\Ucp\CartsCancel::class,
],
];
}
diff --git a/plugins/woocommerce/src/StoreApi/SchemaController.php b/plugins/woocommerce/src/StoreApi/SchemaController.php
index 574d85a0a54..27df0aeb1bb 100644
--- a/plugins/woocommerce/src/StoreApi/SchemaController.php
+++ b/plugins/woocommerce/src/StoreApi/SchemaController.php
@@ -61,6 +61,8 @@ class SchemaController {
Schemas\V1\ShopperListItemSchema::IDENTIFIER => Schemas\V1\ShopperListItemSchema::class,
Schemas\V1\Ucp\UcpCatalogSchema::IDENTIFIER => Schemas\V1\Ucp\UcpCatalogSchema::class,
Schemas\V1\Ucp\UcpProductSchema::IDENTIFIER => Schemas\V1\Ucp\UcpProductSchema::class,
+ Schemas\V1\Ucp\UcpCartSchema::IDENTIFIER => Schemas\V1\Ucp\UcpCartSchema::class,
+ Schemas\V1\Ucp\UcpCartItemSchema::IDENTIFIER => Schemas\V1\Ucp\UcpCartItemSchema::class,
],
];
}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartItemSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartItemSchema.php
new file mode 100644
index 00000000000..691a52877a9
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartItemSchema.php
@@ -0,0 +1,54 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp;
+
+use Automattic\WooCommerce\Enums\TaxDisplayMode;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema;
+
+/**
+ * Cart item schema whose responses carry raw strings and pre-tax prices, for the
+ * UCP cart routes.
+ *
+ * Agents compare titles literally, so the storefront HTML formatter must not
+ * reach them. Same rule as {@see UcpProductSchema}. Prices are always exclusive
+ * of tax: UCP itemises tax in `totals`, so `price` times quantity must equal the
+ * line subtotal whatever the store's cart display setting.
+ *
+ * @since 11.3.0
+ */
+class UcpCartItemSchema extends CartItemSchema {
+ /**
+ * The schema item name.
+ *
+ * @var string
+ */
+ protected $title = 'ucp-cart-item';
+
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-cart-item';
+
+ /**
+ * Return content unformatted.
+ *
+ * @param string|array $response Data to format.
+ * @return string|array
+ */
+ protected function prepare_html_response( $response ) {
+ return $response;
+ }
+
+ /**
+ * Price item prices before tax, ignoring the cart display setting.
+ *
+ * @param string $tax_display_mode Requested mode, ignored.
+ * @return string
+ */
+ protected function get_tax_display_mode( $tax_display_mode = '' ) {
+ return TaxDisplayMode::EXCLUSIVE;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartSchema.php
new file mode 100644
index 00000000000..46ffe002685
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCartSchema.php
@@ -0,0 +1,85 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
+use Automattic\WooCommerce\StoreApi\Utilities\CartController;
+
+/**
+ * Cart schema the UCP cart routes read from.
+ *
+ * The Store API cart response already applies cart tax display, minor units,
+ * image filters and stock validation; the UCP cart mapper reshapes it. Strings
+ * stay raw, and items come from {@see UcpCartItemSchema} for the same reason.
+ *
+ * @since 11.3.0
+ */
+class UcpCartSchema extends CartSchema {
+ /**
+ * The schema item name.
+ *
+ * @var string
+ */
+ protected $title = 'ucp-cart';
+
+ /**
+ * The schema item identifier.
+ *
+ * @var string
+ */
+ const IDENTIFIER = 'ucp-cart';
+
+ /**
+ * Constructor.
+ *
+ * @param ExtendSchema $extend Rest Extending instance.
+ * @param SchemaController $controller Schema Controller instance.
+ */
+ public function __construct( ExtendSchema $extend, SchemaController $controller ) {
+ parent::__construct( $extend, $controller );
+ /**
+ * The controller returns the registered subclass; typed for static analysis.
+ *
+ * @var UcpCartItemSchema $item_schema
+ */
+ $item_schema = $this->controller->get( UcpCartItemSchema::IDENTIFIER );
+ $this->item_schema = $item_schema;
+ }
+
+ /**
+ * Return content unformatted.
+ *
+ * @param string|array $response Data to format.
+ * @return string|array
+ */
+ protected function prepare_html_response( $response ) {
+ return $response;
+ }
+
+ /**
+ * Get cart validation errors, with messages left raw.
+ *
+ * The parent runs each error through {@see \Automattic\WooCommerce\StoreApi\Schemas\V1\ErrorSchema},
+ * which texturizes the message; agents get the plain wording.
+ *
+ * @param \WC_Cart $cart Cart class instance.
+ * @return array Each `{ code, message }`.
+ */
+ protected function get_cart_errors( $cart ) {
+ $cart_errors = array();
+
+ foreach ( (array) ( new CartController() )->get_cart_errors()->errors as $code => $messages ) {
+ foreach ( (array) $messages as $message ) {
+ $cart_errors[] = array(
+ 'code' => (string) $code,
+ 'message' => (string) $message,
+ );
+ }
+ }
+
+ return $cart_errors;
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
index e70c77a0cf9..51523691ded 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/CartController.php
@@ -250,8 +250,10 @@ class CartController {
*
* @param string $item_id Cart item id.
* @param int|float $quantity Cart quantity.
+ * @param bool $refresh_totals Whether to recalculate totals now; pass false when changing several
+ * lines and calling calculate_totals() once afterwards.
*/
- public function set_cart_item_quantity( $item_id, $quantity = 1 ) {
+ public function set_cart_item_quantity( $item_id, $quantity = 1, $refresh_totals = true ) {
$cart_item = $this->get_cart_item( $item_id );
if ( empty( $cart_item ) ) {
@@ -271,7 +273,7 @@ class CartController {
}
$cart = $this->get_cart_instance();
- $cart->set_quantity( $item_id, $quantity );
+ $cart->set_quantity( $item_id, $quantity, $refresh_totals );
}
/**
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UcpCartMapper.php b/plugins/woocommerce/src/StoreApi/Utilities/UcpCartMapper.php
new file mode 100644
index 00000000000..8e03356aa8d
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UcpCartMapper.php
@@ -0,0 +1,193 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities;
+
+/**
+ * Maps a Store API cart response onto the UCP cart shape.
+ *
+ * Reads {@see \Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema} output, so
+ * cart tax display, minor units, image filters and stock validation follow the
+ * storefront. Stateless; the catalog's {@see \Automattic\WooCommerce\Internal\ProductFeed\Mapping\UcpProductMapper}
+ * is the same idea for products.
+ */
+class UcpCartMapper {
+ /**
+ * Build the UCP cart payload.
+ *
+ * @param array $cart Store API cart response, as nested arrays.
+ * @param string $cart_id Cart identifier (a Store API cart token).
+ * @param array<string, mixed> $context Optional `messages` to merge in.
+ * @return array
+ */
+ public function build_cart( array $cart, string $cart_id, array $context = array() ): array {
+ $payload = array(
+ 'id' => $cart_id,
+ 'currency' => (string) $cart['totals']['currency_code'],
+ 'line_items' => $this->line_items( (array) $cart['items'] ),
+ 'totals' => $this->totals( (array) $cart['totals'] ),
+ );
+
+ $expires_at = $this->expires_at( $cart_id );
+ if ( '' !== $expires_at ) {
+ $payload['expires_at'] = $expires_at;
+ }
+
+ $messages = array_merge(
+ is_array( $context['messages'] ?? null ) ? $context['messages'] : array(),
+ $this->error_messages( (array) ( $cart['errors'] ?? array() ) )
+ );
+ if ( ! empty( $messages ) ) {
+ $payload['messages'] = $messages;
+ }
+
+ return $payload;
+ }
+
+ /**
+ * Build the UCP line items.
+ *
+ * `item.id` is the variation id for variations, matching the ids catalog hands out.
+ *
+ * @param array $items Store API cart items.
+ * @return array
+ */
+ private function line_items( array $items ): array {
+ $line_items = array();
+
+ foreach ( $items as $cart_item ) {
+ // The Store API keeps variation attributes apart from the name; a UCP title is
+ // one string, so rejoin them as "Parent - a, b". WooCommerce builds stored
+ // variation names the same way but leaves the attributes out past two; here
+ // every attribute is kept, since the agent has no other place to read them.
+ $title = (string) $cart_item['name'];
+ $values = array_column( (array) $cart_item['variation'], 'value' );
+ if ( ! empty( $values ) ) {
+ $title .= ' - ' . implode( ', ', $values );
+ }
+
+ $item = array(
+ 'id' => (string) $cart_item['id'],
+ 'title' => $title,
+ 'price' => (int) $cart_item['prices']['price'],
+ );
+
+ $image_url = $cart_item['images'][0]['thumbnail'] ?? '';
+ if ( is_string( $image_url ) && '' !== $image_url ) {
+ $item['image_url'] = $image_url;
+ }
+
+ $line_items[] = array(
+ 'id' => (string) $cart_item['key'],
+ 'item' => $item,
+ 'quantity' => (int) $cart_item['quantity'],
+ 'totals' => array(
+ array(
+ 'type' => 'subtotal',
+ 'amount' => (int) $cart_item['totals']['line_subtotal'],
+ ),
+ array(
+ 'type' => 'total',
+ 'amount' => (int) $cart_item['totals']['line_total'],
+ ),
+ ),
+ );
+ }
+
+ return $line_items;
+ }
+
+ /**
+ * Build the totals breakdown.
+ *
+ * `subtotal`, `tax` and `total` are always present; a zero `tax` is valid (schema
+ * `minimum` 0) and tells the agent no tax applied. `discount`/`fulfillment`/`fee` are
+ * dropped when zero — the schema forbids a zero `discount` (`exclusiveMaximum` 0), and a
+ * zero shipping or fee is nothing to show. Discounts stay order-level: no agent can apply
+ * a code without the discounts extension, so there is nothing to attribute.
+ *
+ * @param array $cart_totals Store API cart totals, already in minor units.
+ * @return array
+ */
+ private function totals( array $cart_totals ): array {
+ $totals = array(
+ array(
+ 'type' => 'subtotal',
+ 'amount' => (int) $cart_totals['total_items'],
+ ),
+ );
+
+ $discount = (int) $cart_totals['total_discount'];
+ if ( $discount > 0 ) {
+ $totals[] = array(
+ 'type' => 'discount',
+ 'amount' => -$discount,
+ );
+ }
+
+ // Null until shipping is calculated.
+ $shipping = (int) ( $cart_totals['total_shipping'] ?? 0 );
+ if ( $shipping > 0 ) {
+ $totals[] = array(
+ 'type' => 'fulfillment',
+ 'amount' => $shipping,
+ );
+ }
+
+ $fees = (int) $cart_totals['total_fees'];
+ if ( $fees > 0 ) {
+ $totals[] = array(
+ 'type' => 'fee',
+ 'amount' => $fees,
+ );
+ }
+
+ $totals[] = array(
+ 'type' => 'tax',
+ 'amount' => (int) $cart_totals['total_tax'],
+ );
+
+ $totals[] = array(
+ 'type' => 'total',
+ 'amount' => (int) $cart_totals['total_price'],
+ );
+
+ return $totals;
+ }
+
+ /**
+ * Surface the cart's own validation errors, such as stock that ran out since the
+ * item was added, as recoverable messages.
+ *
+ * @param array $errors Store API cart errors, each `{ code, message }`.
+ * @return array
+ */
+ private function error_messages( array $errors ): array {
+ $messages = array();
+
+ foreach ( $errors as $error ) {
+ $messages[] = array(
+ 'type' => 'warning',
+ 'code' => UcpUtils::cart_error_code( (string) ( $error['code'] ?? '' ) ),
+ 'content' => (string) ( $error['message'] ?? '' ),
+ 'severity' => 'recoverable',
+ 'path' => '$.line_items',
+ 'content_type' => 'plain',
+ );
+ }
+
+ return $messages;
+ }
+
+ /**
+ * Resolve the cart's expiry from its token.
+ *
+ * @param string $cart_id Cart identifier.
+ * @return string RFC 3339 timestamp, or '' when the token carries no expiry.
+ */
+ private function expires_at( string $cart_id ): string {
+ $expiry = (int) ( CartTokenUtils::get_cart_token_payload( $cart_id )['exp'] ?? 0 );
+
+ return $expiry > 0 ? gmdate( 'c', $expiry ) : '';
+ }
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
index 83ccbb12f94..9f77520eaf7 100644
--- a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
@@ -29,6 +29,11 @@ class UcpUtils {
*/
const CAPABILITY_CATALOG_LOOKUP = 'dev.ucp.shopping.catalog.lookup';
+ /**
+ * Capability served by the `/carts` routes.
+ */
+ const CAPABILITY_CART = 'dev.ucp.shopping.cart';
+
/**
* Whether the UCP routes are registered.
*
@@ -75,6 +80,11 @@ class UcpUtils {
'spec' => 'https://ucp.dev/' . self::VERSION . '/specification/catalog/lookup',
'schema' => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/catalog_lookup.json',
),
+ self::CAPABILITY_CART => array(
+ 'version' => self::VERSION,
+ 'spec' => 'https://ucp.dev/' . self::VERSION . '/specification/cart',
+ 'schema' => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/cart.json',
+ ),
);
}
@@ -87,4 +97,66 @@ class UcpUtils {
private static function capability_declaration( string $capability ): array {
return self::capability_declarations()[ $capability ] ?? array( 'version' => self::VERSION );
}
+
+ /**
+ * Build a UCP error response.
+ *
+ * The envelope shape every route shares. `$status` follows the UCP split: a malformed
+ * request keeps a non-2xx status, a valid request that ends in a refusal is a business
+ * outcome reported at 200 through `ucp.status` and `messages`.
+ *
+ * @param string $capability One of the CAPABILITY_* constants.
+ * @param string $code Error code.
+ * @param string $message Error message.
+ * @param string $severity Error severity.
+ * @param int $status HTTP status.
+ * @param string $path JSONPath of the offending field.
+ * @return \WP_REST_Response
+ */
+ public static function error_response( string $capability, string $code, string $message, string $severity, int $status, string $path = '$' ): \WP_REST_Response {
+ return new \WP_REST_Response(
+ array(
+ 'ucp' => self::response_metadata( $capability, 'error' ),
+ 'messages' => array(
+ array(
+ 'type' => 'error',
+ 'code' => $code,
+ 'content' => $message,
+ 'severity' => $severity,
+ 'path' => $path,
+ 'content_type' => 'plain',
+ ),
+ ),
+ ),
+ $status
+ );
+ }
+
+ /**
+ * Translate a cart error code into the UCP code closest to it.
+ *
+ * The cart names more reasons than UCP does, so the ones an agent can act on
+ * differently — retry smaller, fix the quantity, or re-read the cart — stay apart
+ * and the rest collapse.
+ *
+ * @param string $code Store API error code.
+ * @param string $fallback UCP code for a cart error UCP has no closer name for.
+ * @return string One of `out_of_stock`, `not_found`, `invalid_quantity` or the fallback.
+ */
+ public static function cart_error_code( string $code, string $fallback = 'item_unavailable' ): string {
+ switch ( $code ) {
+ case 'woocommerce_rest_product_out_of_stock':
+ case 'woocommerce_rest_product_partially_out_of_stock':
+ case 'woocommerce_rest_product_too_many_in_cart':
+ return 'out_of_stock';
+ case 'woocommerce_rest_cart_invalid_key':
+ return 'not_found';
+ case 'woocommerce_rest_product_invalid_quantity':
+ case 'invalid_quantity':
+ case 'readonly_quantity':
+ return 'invalid_quantity';
+ default:
+ return $fallback;
+ }
+ }
}
diff --git a/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php b/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
index 9bd14dd3985..0698ff7c204 100644
--- a/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-woocommerce-test.php
@@ -139,6 +139,25 @@ class WooCommerce_Test extends \WC_Unit_Test_Case {
$this->assertFalse( WC()->is_store_api_request(), 'A non-Store API ?rest_route= request should not be detected as Store API.' );
}
+ /**
+ * @testdox Should detect a UCP request as a Store API request, since it also returns data rather than pages.
+ */
+ public function test_is_store_api_request_returns_true_for_ucp_pretty_permalinks(): void {
+ $_SERVER['REQUEST_URI'] = '/wp-json/wc/ucp/v1/carts';
+
+ $this->assertTrue( WC()->is_store_api_request(), 'A /wp-json/wc/ucp/ path should be detected as Store API.' );
+ }
+
+ /**
+ * @testdox Should detect a plain-permalink UCP request as a Store API request.
+ */
+ public function test_is_store_api_request_returns_true_for_ucp_plain_permalinks(): void {
+ $_SERVER['REQUEST_URI'] = '/index.php?rest_route=/wc/ucp/v1/carts';
+ $_GET['rest_route'] = '/wc/ucp/v1/carts';
+
+ $this->assertTrue( WC()->is_store_api_request(), 'A ?rest_route=/wc/ucp/ request should be detected as Store API.' );
+ }
+
/**
* @testdox Should not detect a Store-API-like value in a query argument as a Store API request.
*/
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
new file mode 100644
index 00000000000..2659c0a934d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCart.php
@@ -0,0 +1,1170 @@
+<?php
+/**
+ * UCP Cart Route Tests.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\WooCommerce\StoreApi\Routes\V1\Ucp\AbstractCartRoute;
+use Automattic\WooCommerce\StoreApi\SessionHandler;
+use Automattic\WooCommerce\StoreApi\Utilities\CartTokenUtils;
+
+/**
+ * Tests for the /wc/ucp/v1/carts endpoints.
+ */
+class UcpCart extends ControllerTestCase {
+ /**
+ * Published product IDs shared by the class, oldest first.
+ *
+ * @var int[]
+ */
+ private static $product_ids = array();
+
+ /**
+ * A product with exactly one unit in stock.
+ *
+ * @var int
+ */
+ private static $limited_stock_id = 0;
+
+ /**
+ * Variable parent id and one of its variation ids.
+ *
+ * @var array{parent: int, variation: int}
+ */
+ private static $variable = array(
+ 'parent' => 0,
+ 'variation' => 0,
+ );
+
+ /**
+ * Option behind the `ucp` feature.
+ */
+ private const FEATURE_OPTION = 'woocommerce_feature_ucp_enabled';
+
+ /**
+ * Create immutable fixtures before route registration starts.
+ */
+ public static function wpSetUpBeforeClass(): void {
+ $products = self::create_class_fixture_products(
+ array(
+ array(
+ 'name' => 'UCP Cart Mug',
+ 'regular_price' => '19.99',
+ 'price' => '19.99',
+ 'sku' => 'ucp-cart-mug',
+ ),
+ array(
+ 'name' => 'UCP Cart Poster',
+ 'regular_price' => '5.00',
+ 'price' => '5.00',
+ 'sku' => 'ucp-cart-poster',
+ ),
+ )
+ );
+
+ self::$product_ids = array_map(
+ static function ( $product ) {
+ return $product->get_id();
+ },
+ $products
+ );
+
+ $limited = self::create_class_fixture_products(
+ array(
+ array(
+ 'name' => 'UCP Cart Last One',
+ 'regular_price' => '9.00',
+ 'price' => '9.00',
+ 'manage_stock' => true,
+ 'stock_quantity' => 1,
+ ),
+ )
+ );
+
+ self::$limited_stock_id = $limited[0]->get_id();
+
+ $fixtures = new \Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData();
+ $parent = $fixtures->get_variable_product( array( 'name' => 'UCP Cart Tee' ), array( $fixtures->get_product_attribute( 'size', array( 'small', 'large' ) ) ) );
+ $variation = $fixtures->get_variation_product(
+ $parent->get_id(),
+ array( 'pa_size' => 'small' ),
+ array( 'regular_price' => '12.00' )
+ );
+
+ // Sync so the parent reports a price and therefore passes is_purchasable(), as a
+ // real variable product does — that is what makes the parent reach add_to_cart
+ // unless the route refuses it first.
+ \WC_Product_Variable::sync( $parent->get_id() );
+
+ self::$variable = array(
+ 'parent' => $parent->get_id(),
+ 'variation' => $variation->get_id(),
+ );
+ }
+
+ /**
+ * Delete class fixtures.
+ */
+ public static function wpTearDownAfterClass(): void {
+ self::delete_class_fixture_products(
+ array_merge(
+ self::$product_ids,
+ array( self::$limited_stock_id, self::$variable['variation'], self::$variable['parent'] )
+ )
+ );
+ }
+
+ /**
+ * The session, customer and cart the harness set up, so `simulate_new_request()`
+ * can swap them for a test and tearDown can put them back.
+ *
+ * @var array{session: \WC_Session|null, customer: \WC_Customer|null, cart: \WC_Cart|null, loaded: int|null, totals: int|null}
+ */
+ private $wc_globals_backup = array();
+
+ /**
+ * Enable the UCP routes and rebuild the REST server so they register.
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ // Inside the per-test transaction, so the rollback turns the flag off again;
+ // the routes register on the rebuilt server.
+ update_option( self::FEATURE_OPTION, 'yes' );
+ $this->initialize_store_api_server();
+
+ // A per-test DB transaction rolls back rows but not the WC() singletons, so a test
+ // that swaps them would leak into the next one. Keep the originals to restore.
+ $this->wc_globals_backup = array(
+ 'session' => WC()->session,
+ 'customer' => WC()->customer,
+ 'cart' => WC()->cart,
+ 'loaded' => $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] ?? null,
+ 'totals' => $GLOBALS['wp_actions']['woocommerce_after_calculate_totals'] ?? null,
+ );
+
+ $this->simulate_new_request();
+ }
+
+ /**
+ * Restore the cancellation marker and the WC() singletons.
+ */
+ protected function tearDown(): void {
+ unset( $_SERVER['HTTP_CART_TOKEN'] );
+
+ if ( WC()->session ) {
+ WC()->session->set( AbstractCartRoute::CANCELLED_SESSION_KEY, null );
+ }
+
+ if ( WC()->cart instanceof \WC_Cart ) {
+ \WC_Cart_Session::set_updates_enabled_for_cart( WC()->cart, true );
+ }
+
+ // Undo any `simulate_new_request()` swap so the next test gets the cart, session and
+ // customer the harness built, not a bare one left behind here.
+ WC()->session = $this->wc_globals_backup['session'];
+ WC()->customer = $this->wc_globals_backup['customer'];
+ WC()->cart = $this->wc_globals_backup['cart'];
+ $markers = array(
+ 'woocommerce_load_cart_from_session' => 'loaded',
+ 'woocommerce_after_calculate_totals' => 'totals',
+ );
+ foreach ( $markers as $action => $key ) {
+ if ( null === $this->wc_globals_backup[ $key ] ) {
+ unset( $GLOBALS['wp_actions'][ $action ] );
+ } else {
+ $GLOBALS['wp_actions'][ $action ] = $this->wc_globals_backup[ $key ]; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the per-process markers the harness cleared.
+ }
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Dispatch a request with a JSON body.
+ *
+ * @param string $method HTTP method.
+ * @param string $path Route path under /wc/ucp/v1.
+ * @param array $body Request body.
+ * @return \WP_REST_Response
+ */
+ private function request( string $method, string $path, array $body = array() ) {
+ $request = new \WP_REST_Request( $method, '/wc/ucp/v1' . $path );
+ $request->set_header( 'content-type', 'application/json' );
+ $request->set_body( wp_json_encode( $body ) );
+
+ return rest_get_server()->dispatch( $request );
+ }
+
+ /**
+ * Create a cart holding the given line items.
+ *
+ * @param array $line_items UCP line items.
+ * @return array The response data.
+ */
+ private function create_cart( array $line_items ): array {
+ $response = $this->request( 'POST', '/carts', array( 'line_items' => $line_items ) );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ return $response->get_data();
+ }
+
+ /**
+ * Build a UCP line item for one of the fixture products.
+ *
+ * @param int $index Fixture index.
+ * @param int $quantity Quantity.
+ * @return array
+ */
+ private function line_item( int $index, int $quantity ): array {
+ return array(
+ 'item' => array( 'id' => (string) self::$product_ids[ $index ] ),
+ 'quantity' => $quantity,
+ );
+ }
+
+ /**
+ * Start a request the way a fresh HTTP request would.
+ *
+ * The session and cart are loaded once per process. Persist what the current
+ * request would save on shutdown, then drop them and the "loaded" marker so the
+ * next route builds its own session handler through `wc_load_cart()`, exactly as a
+ * real REST request does; a UCP route then binds the token handler to the token it
+ * minted or was given.
+ *
+ * @param string $cart_token Cart token the next request carries, or '' for none.
+ */
+ private function simulate_new_request( string $cart_token = '' ): void {
+ if ( WC()->session ) {
+ // WC_Session_Handler::save_data() is a no-op without a session cookie, and no
+ // cookie is ever sent in this suite. A real cart request sets one through
+ // woocommerce_set_cart_cookies, so flip the same flag before saving.
+ if ( WC()->session instanceof \WC_Session_Handler ) {
+ $has_cookie = new \ReflectionProperty( WC()->session, '_has_cookie' );
+ $has_cookie->setAccessible( true );
+ $has_cookie->setValue( WC()->session, true );
+ }
+
+ WC()->session->save_data();
+ }
+
+ if ( '' === $cart_token ) {
+ unset( $_SERVER['HTTP_CART_TOKEN'] );
+ } else {
+ $_SERVER['HTTP_CART_TOKEN'] = $cart_token;
+ }
+
+ WC()->session = null;
+ WC()->customer = null;
+ WC()->cart = null;
+ // Per-process "already done" markers a real request would not carry over.
+ unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'], $GLOBALS['wp_actions']['woocommerce_after_calculate_totals'] );
+ }
+
+ /**
+ * Read one entry out of a UCP totals array.
+ *
+ * @param array $totals Totals array.
+ * @param string $type Total type.
+ * @return int|null
+ */
+ private function total( array $totals, string $type ) {
+ foreach ( $totals as $entry ) {
+ if ( $type === $entry['type'] ) {
+ return $entry['amount'];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * The routes must not exist unless a site opts in.
+ */
+ public function test_routes_are_not_registered_by_default(): void {
+ delete_option( self::FEATURE_OPTION );
+ $this->initialize_store_api_server();
+
+ $this->assertSame( 404, $this->request( 'POST', '/carts' )->get_status() );
+ $this->assertSame( 404, $this->request( 'GET', '/carts/abc' )->get_status() );
+ $this->assertSame( 404, $this->request( 'POST', '/carts/abc/cancel' )->get_status() );
+ }
+
+ /**
+ * A UCP cart route never reads the browser's session cookie.
+ *
+ * Cart creation carries no token, so the parent would leave the cookie handler in
+ * place and bind the route to whatever cart the caller's cookie names. With
+ * `line_items` read as the complete desired state, a cross-site POST would then
+ * rewrite that shopper's cart, and `requires_nonce()` returning false leaves nothing
+ * else in the way. The handler is fixed for every route here instead.
+ */
+ public function test_cart_routes_never_bind_to_the_session_cookie(): void {
+ unset( $_SERVER['HTTP_CART_TOKEN'] );
+
+ $routes = new \Automattic\WooCommerce\StoreApi\RoutesController( new \Automattic\WooCommerce\StoreApi\SchemaController( $this->mock_extend ) );
+ $route = $routes->get( 'ucp-carts', 'ucp' );
+
+ $bind = new \ReflectionMethod( $route, 'load_cart_session' );
+ $bind->setAccessible( true );
+ $bind->invoke( $route, new \WP_REST_Request( 'POST', '/wc/ucp/v1/carts' ) );
+
+ try {
+ $this->assertSame(
+ SessionHandler::class,
+ apply_filters( 'woocommerce_session_handler', \WC_Session_Handler::class )
+ );
+ } finally {
+ remove_filter( 'woocommerce_session_handler', array( AbstractCartRoute::class, 'session_handler_class' ) );
+ }
+ }
+
+ /**
+ * A request naming more lines than the ceiling is refused before any product lookup.
+ */
+ public function test_create_cart_refuses_too_many_line_items(): void {
+ $line_items = array_fill( 0, AbstractCartRoute::LINE_ITEMS_MAX + 1, $this->line_item( 0, 1 ) );
+
+ $response = $this->request( 'POST', '/carts', array( 'line_items' => $line_items ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'VALIDATION_ERROR', $data['messages'][0]['code'] );
+ $this->assertSame( '$.line_items', $data['messages'][0]['path'] );
+
+ // Exactly the ceiling is still accepted.
+ $this->create_cart( array_slice( $line_items, 0, AbstractCartRoute::LINE_ITEMS_MAX ) );
+ }
+
+ /**
+ * A product that exists but no cart line can hold — here a grouped product — is
+ * reported `item_unavailable`, not `not_found`: the id resolves, it just is not orderable.
+ */
+ public function test_create_cart_refuses_an_unpurchasable_product_type(): void {
+ $grouped = new \WC_Product_Grouped();
+ $grouped->set_name( 'UCP Grouped' );
+ $grouped_id = $grouped->save();
+
+ $response = $this->request( 'POST', '/carts', array( 'line_items' => array( array( 'item' => array( 'id' => (string) $grouped_id ) ) ) ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'item_unavailable', $data['messages'][0]['code'] );
+ $this->assertSame( '$.line_items[0].item.id', $data['messages'][0]['path'] );
+ }
+
+ /**
+ * An id that matches no product at all stays `not_found`, keeping it apart from a
+ * product that exists but cannot be ordered.
+ */
+ public function test_create_cart_reports_an_unknown_product_id_as_not_found(): void {
+ $response = $this->request( 'POST', '/carts', array( 'line_items' => array( array( 'item' => array( 'id' => '99999999' ) ) ) ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'not_found', $data['messages'][0]['code'] );
+ }
+
+ /**
+ * A quantity-limit refusal is reported as `invalid_quantity`, distinct from a gone item.
+ *
+ * An agent can act on the two differently — fix the quantity vs. drop the line — so the
+ * mapping keeps them apart instead of collapsing both to `item_unavailable`.
+ */
+ public function test_update_cart_reports_a_quantity_limit_as_invalid_quantity(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ // Cap the product at one per cart, then ask for two.
+ $cap = static function () {
+ return 1;
+ };
+ add_filter( 'woocommerce_store_api_product_quantity_maximum', $cap );
+
+ $response = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array( $this->line_item( 0, 2 ) ) ) );
+ $data = $response->get_data();
+
+ remove_filter( 'woocommerce_store_api_product_quantity_maximum', $cap );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'invalid_quantity', $data['messages'][0]['code'] );
+
+ // A refused update leaves the cart alone.
+ $this->assertCount( 1, $this->request( 'GET', '/carts/' . $created['id'] )->get_data()['line_items'] );
+ }
+
+ /**
+ * A failure the Store API base class reports in its own shape is re-shaped for UCP.
+ *
+ * The parent refuses to load a cart whose session failed earlier in the process; that
+ * is the same exit a throwing plugin or a session error reaches.
+ */
+ public function test_errors_from_the_store_api_base_class_wear_the_ucp_envelope(): void {
+ $cart_id = $this->create_cart( array( $this->line_item( 0, 1 ) ) )['id'];
+
+ \WC_Cart_Session::set_updates_enabled_for_cart( WC()->cart, false );
+
+ $response = $this->request( 'GET', '/carts/' . $cart_id );
+ $data = $response->get_data();
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'TRANSPORT_ERROR', $data['messages'][0]['code'] );
+ $this->assertSame( 'unrecoverable', $data['messages'][0]['severity'] );
+ $this->assertArrayNotHasKey( 'code', $data, 'The Store API error shape must not leak through.' );
+ }
+
+ /**
+ * A cart error the parent reports keeps its UCP code and status; only errors UCP has
+ * no name for become transport errors.
+ */
+ public function test_parent_errors_keep_their_cart_code(): void {
+ $route = rest_get_server()->get_routes()['/wc/ucp/v1/carts'][0]['callback'][0];
+ $method = new \ReflectionMethod( $route, 'error_to_response' );
+ $method->setAccessible( true );
+
+ $refused = $method->invoke( $route, new \WP_Error( 'woocommerce_rest_product_out_of_stock', 'Gone.', array( 'status' => 409 ) ) );
+ $this->assertSame( 409, $refused->get_status() );
+ $this->assertSame( 'out_of_stock', $refused->get_data()['messages'][0]['code'] );
+ $this->assertSame( 'recoverable', $refused->get_data()['messages'][0]['severity'] );
+ $this->assertSame( 'Gone.', $refused->get_data()['messages'][0]['content'] );
+
+ $unknown = $method->invoke( $route, new \WP_Error( 'woocommerce_rest_cart_error', 'Session failed.', array( 'status' => 500 ) ) );
+ $this->assertSame( 500, $unknown->get_status() );
+ $this->assertSame( 'TRANSPORT_ERROR', $unknown->get_data()['messages'][0]['code'] );
+ $this->assertSame( 'unrecoverable', $unknown->get_data()['messages'][0]['severity'] );
+ }
+
+ /**
+ * Creating a cart answers the UCP cart shape, with amounts in minor units.
+ */
+ public function test_create_cart_returns_ucp_cart(): void {
+ $data = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+
+ $this->assertSame( '2026-04-08', $data['ucp']['version'] );
+ $this->assertSame( 'success', $data['ucp']['status'] );
+ // The envelope names only the capability relevant to the operation answered.
+ $this->assertSame( array( 'dev.ucp.shopping.cart' ), array_keys( $data['ucp']['capabilities'] ) );
+
+ $this->assertNotEmpty( $data['id'] );
+ $this->assertSame( get_woocommerce_currency(), $data['currency'] );
+
+ $this->assertCount( 1, $data['line_items'] );
+ $line_item = $data['line_items'][0];
+ $this->assertSame( (string) self::$product_ids[0], $line_item['item']['id'] );
+ $this->assertSame( 'UCP Cart Mug', $line_item['item']['title'] );
+ $this->assertSame( 1999, $line_item['item']['price'] );
+ $this->assertSame( 2, $line_item['quantity'] );
+ $this->assertSame( 3998, $this->total( $line_item['totals'], 'subtotal' ) );
+
+ $this->assertSame( 3998, $this->total( $data['totals'], 'subtotal' ) );
+ $this->assertSame( 3998, $this->total( $data['totals'], 'total' ) );
+
+ // `tax` is always present, zero included (schema allows it); a zero `discount` is omitted.
+ $this->assertSame( 0, $this->total( $data['totals'], 'tax' ) );
+ $this->assertNull( $this->total( $data['totals'], 'discount' ) );
+ }
+
+ /**
+ * A cart reads back with the contents it was created with.
+ */
+ public function test_get_cart_returns_the_same_contents(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ $response = $this->request( 'GET', '/carts/' . $created['id'] );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( $created['id'], $data['id'] );
+ $this->assertSame( $created['line_items'], $data['line_items'] );
+ }
+
+ /**
+ * An update is a full replacement: a product left out of `line_items` is removed.
+ */
+ public function test_update_cart_replaces_line_items(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ $both = $this->request(
+ 'PUT',
+ '/carts/' . $created['id'],
+ array(
+ 'line_items' => array(
+ $this->line_item( 0, 3 ),
+ $this->line_item( 1, 1 ),
+ ),
+ )
+ )->get_data();
+
+ $this->assertCount( 2, $both['line_items'] );
+ $this->assertSame( array( 3, 1 ), wp_list_pluck( $both['line_items'], 'quantity' ) );
+
+ // Dropping a line must remove it, not merge the two payloads.
+ $dropped = $this->request(
+ 'PUT',
+ '/carts/' . $created['id'],
+ array( 'line_items' => array( $this->line_item( 1, 1 ) ) )
+ )->get_data();
+
+ $this->assertCount( 1, $dropped['line_items'] );
+ $this->assertSame( (string) self::$product_ids[1], $dropped['line_items'][0]['item']['id'] );
+ $this->assertSame( 500, $this->total( $dropped['totals'], 'total' ) );
+ }
+
+ /**
+ * Add a second line of a fixture product, distinguished only by cart item data.
+ *
+ * This is the shape a product add-on or personalization field produces: the same
+ * product on two lines, because the cart item key hashes the extra data.
+ *
+ * @param int $index Fixture index.
+ * @param int $quantity Quantity.
+ */
+ private function add_personalized_line( int $index, int $quantity ): void {
+ WC()->cart->add_to_cart( self::$product_ids[ $index ], $quantity, 0, array(), array( 'engraving' => 'For Ada' ) );
+ WC()->cart->calculate_totals();
+ }
+
+ /**
+ * Echoing a cart back unchanged must leave it unchanged.
+ *
+ * Two lines of one product are told apart by `line_items[].id`, the cart item key.
+ * Reconciling on the product alone would write the summed quantity onto one line and
+ * leave the other standing, so a no-op round trip would grow the cart every time.
+ */
+ public function test_update_cart_round_trip_does_not_inflate_duplicate_lines(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->add_personalized_line( 0, 2 );
+
+ $read = $this->request( 'GET', '/carts/' . $created['id'] )->get_data();
+
+ $this->assertCount( 2, $read['line_items'] );
+ $this->assertSame( 5997, $this->total( $read['totals'], 'total' ) );
+ $this->assertNotSame( $read['line_items'][0]['id'], $read['line_items'][1]['id'] );
+
+ $echoed = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => $read['line_items'] ) )->get_data();
+
+ $this->assertSame( array( 1, 2 ), wp_list_pluck( $echoed['line_items'], 'quantity' ) );
+ $this->assertSame( 5997, $this->total( $echoed['totals'], 'total' ) );
+ }
+
+ /**
+ * Clearing the cart removes every line, including duplicates of one product.
+ */
+ public function test_update_cart_clears_duplicate_lines_of_one_product(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->add_personalized_line( 0, 2 );
+
+ $cleared = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array() ) )->get_data();
+
+ $this->assertSame( array(), $cleared['line_items'] );
+ }
+
+ /**
+ * A payload naming no line ids describes the cart by product, so duplicates collapse.
+ */
+ public function test_update_cart_collapses_duplicate_lines_when_no_id_is_given(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->add_personalized_line( 0, 2 );
+
+ $collapsed = $this->request(
+ 'PUT',
+ '/carts/' . $created['id'],
+ array( 'line_items' => array( $this->line_item( 0, 4 ) ) )
+ )->get_data();
+
+ $this->assertCount( 1, $collapsed['line_items'] );
+ $this->assertSame( 4, $collapsed['line_items'][0]['quantity'] );
+ $this->assertSame( 7996, $this->total( $collapsed['totals'], 'total' ) );
+ }
+
+ /**
+ * A line id the cart no longer holds falls back to the product it names.
+ */
+ public function test_update_cart_ignores_a_stale_line_id(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ $stale = array_merge( $this->line_item( 0, 2 ), array( 'id' => 'a-line-that-is-gone' ) );
+
+ $updated = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array( $stale ) ) )->get_data();
+
+ $this->assertCount( 1, $updated['line_items'] );
+ $this->assertSame( 2, $updated['line_items'][0]['quantity'] );
+ }
+
+ /**
+ * Omitting `line_items` leaves the cart alone; an empty list clears it.
+ */
+ public function test_update_cart_distinguishes_omitted_from_empty_line_items(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ $untouched = $this->request( 'PUT', '/carts/' . $created['id'] )->get_data();
+ $this->assertCount( 1, $untouched['line_items'] );
+
+ $cleared = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array() ) )->get_data();
+ $this->assertSame( array(), $cleared['line_items'] );
+ }
+
+ /**
+ * Only PUT updates a cart, as the cart REST binding specifies.
+ *
+ * PATCH matters most: `line_items` is the complete desired state, so a partial
+ * payload taken as an update would delete every line it left out.
+ */
+ public function test_only_put_updates_a_cart(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ foreach ( array( 'POST', 'PATCH', 'DELETE' ) as $method ) {
+ $response = $this->request( $method, '/carts/' . $created['id'], array( 'line_items' => array() ) );
+
+ $this->assertSame( 404, $response->get_status(), $method . ' on a cart' );
+ $this->assertSame( 'rest_no_route', $response->get_data()['code'], $method . ' on a cart' );
+ }
+
+ // None of them emptied the cart.
+ $this->assertCount( 1, $this->request( 'GET', '/carts/' . $created['id'] )->get_data()['line_items'] );
+ }
+
+ /**
+ * Malformed line items are refused in the envelope, with the offending path.
+ */
+ public function test_update_cart_reports_invalid_line_items(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ $missing_id = $this->request(
+ 'PUT',
+ '/carts/' . $created['id'],
+ array( 'line_items' => array( array( 'quantity' => 1 ) ) )
+ );
+
+ $this->assertSame( 400, $missing_id->get_status() );
+ $this->assertSame( 'error', $missing_id->get_data()['ucp']['status'] );
+ $this->assertSame( 'VALIDATION_ERROR', $missing_id->get_data()['messages'][0]['code'] );
+ $this->assertSame( '$.line_items[0].item.id', $missing_id->get_data()['messages'][0]['path'] );
+
+ $zero_quantity = $this->request(
+ 'PUT',
+ '/carts/' . $created['id'],
+ array( 'line_items' => array( array_merge( $this->line_item( 0, 1 ), array( 'quantity' => 0 ) ) ) )
+ );
+
+ $this->assertSame( 400, $zero_quantity->get_status() );
+ $this->assertSame( '$.line_items[0].quantity', $zero_quantity->get_data()['messages'][0]['path'] );
+
+ // A well-formed payload naming an id the store cannot sell is a business outcome,
+ // reported at 200 like every other `not_found`, not a malformed request.
+ $unknown_product = $this->request(
+ 'PUT',
+ '/carts/' . $created['id'],
+ array(
+ 'line_items' => array(
+ array(
+ 'item' => array( 'id' => '99999999' ),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( 200, $unknown_product->get_status() );
+ $this->assertSame( 'error', $unknown_product->get_data()['ucp']['status'] );
+ $this->assertSame( 'not_found', $unknown_product->get_data()['messages'][0]['code'] );
+ $this->assertSame( '$.line_items[0].item.id', $unknown_product->get_data()['messages'][0]['path'] );
+
+ // The cart itself is untouched by a refused update.
+ $this->assertCount( 1, $this->request( 'GET', '/carts/' . $created['id'] )->get_data()['line_items'] );
+ }
+
+ /**
+ * A variable product is bought by naming one of its variants.
+ *
+ * The variant id is what catalog hands out, and what the cart echoes back, so an id
+ * from search round-trips into a cart without translation.
+ */
+ public function test_create_cart_accepts_a_variation_id(): void {
+ $data = $this->create_cart(
+ array(
+ array(
+ 'item' => array( 'id' => (string) self::$variable['variation'] ),
+ 'quantity' => 2,
+ ),
+ )
+ );
+
+ $this->assertCount( 1, $data['line_items'] );
+ $this->assertSame( (string) self::$variable['variation'], $data['line_items'][0]['item']['id'] );
+ // The parent title plus the variation's attribute values, as WC names a variation.
+ $this->assertSame( 'UCP Cart Tee - small', $data['line_items'][0]['item']['title'] );
+ $this->assertSame( 1200, $data['line_items'][0]['item']['price'] );
+ $this->assertSame( 2400, $this->total( $data['totals'], 'total' ) );
+ }
+
+ /**
+ * A variable parent is refused, in the envelope, telling the agent to name a variant.
+ *
+ * The parent reports a price so it passes `is_purchasable()`, and letting it through
+ * would reach `CartController::add_to_cart()` and surface a Store API error shape with
+ * no `ucp` object for a client to read.
+ */
+ public function test_create_cart_refuses_a_variable_parent(): void {
+ $response = $this->request(
+ 'POST',
+ '/carts',
+ array(
+ 'line_items' => array(
+ array(
+ 'item' => array( 'id' => (string) self::$variable['parent'] ),
+ 'quantity' => 1,
+ ),
+ ),
+ )
+ );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'item_unavailable', $data['messages'][0]['code'] );
+ $this->assertSame( '$.line_items[0].item.id', $data['messages'][0]['path'] );
+ }
+
+ /**
+ * A stock ceiling is reported as `out_of_stock`, in the envelope, with the cart's message.
+ *
+ * The cart distinguishes more refusals than UCP names codes for, so the ones an agent
+ * can act on differently are kept apart: a stock ceiling invites a smaller quantity,
+ * where `item_unavailable` would only say "no".
+ */
+ public function test_update_cart_reports_a_stock_ceiling_as_out_of_stock(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ $response = $this->request(
+ 'PUT',
+ '/carts/' . $created['id'],
+ array(
+ 'line_items' => array(
+ array(
+ 'item' => array( 'id' => (string) self::$limited_stock_id ),
+ 'quantity' => 5,
+ ),
+ ),
+ )
+ );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'out_of_stock', $data['messages'][0]['code'] );
+ // The cart's own wording names the product and what is left, which is actionable.
+ $this->assertStringContainsString( 'not enough stock', $data['messages'][0]['content'] );
+
+ // A refused update leaves the cart alone.
+ $this->assertCount( 1, $this->request( 'GET', '/carts/' . $created['id'] )->get_data()['line_items'] );
+ }
+
+ /**
+ * A product that is entirely out of stock is refused on add, mapped to `out_of_stock`.
+ */
+ public function test_add_out_of_stock_product_is_reported_as_out_of_stock(): void {
+ $product = new \WC_Product_Simple();
+ $product->set_name( 'UCP Sold Out' );
+ $product->set_regular_price( '7.00' );
+ $product->set_stock_status( 'outofstock' );
+ $sold_out_id = $product->save();
+
+ $response = $this->request( 'POST', '/carts', array( 'line_items' => array( array( 'item' => array( 'id' => (string) $sold_out_id ) ) ) ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'out_of_stock', $data['messages'][0]['code'] );
+ $this->assertSame( '$.line_items', $data['messages'][0]['path'] );
+ }
+
+ /**
+ * An id this store never signed is reported as not found, in the envelope.
+ */
+ public function test_unknown_cart_id_is_not_found(): void {
+ $response = $this->request( 'GET', '/carts/not-a-real-token' );
+ $data = $response->get_data();
+
+ // UCP clients read ucp.status and messages, not the HTTP status.
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'not_found', $data['messages'][0]['code'] );
+ $this->assertArrayNotHasKey( 'line_items', $data );
+ $this->assertSame( 'no-store', $response->get_headers()['Cache-Control'] ?? null, 'A 200 not-found must not be cacheable.' );
+ }
+
+ /**
+ * A cart an extension preloaded is not written by a UCP create: it mints its own session.
+ */
+ public function test_preloaded_cookie_session_is_not_written_by_create(): void {
+ // Stand in for an extension that loads the cart on `init`: a cookie session is in
+ // memory and the "loaded" marker is set, so the route's handler swap would be a no-op
+ // unless the route discards it first.
+ WC()->initialize_session();
+ WC()->initialize_cart();
+ WC()->cart->add_to_cart( self::$product_ids[0], 1 );
+ $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] = 1; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Simulates an extension that already loaded the cart.
+
+ $this->assertInstanceOf( \WC_Session_Handler::class, WC()->session );
+ $cookie_customer = WC()->session->get_customer_id();
+
+ $response = $this->request( 'POST', '/carts', array( 'line_items' => array( $this->line_item( 1, 5 ) ) ) );
+ $data = $response->get_data();
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'success', $data['ucp']['status'] );
+
+ // The returned id is signed from the session the create wrote to. It must be a fresh
+ // token, not the preloaded cookie session, or the create clobbered that shopper's cart.
+ $written_customer = CartTokenUtils::get_cart_token_payload( $data['id'] )['user_id'];
+ $this->assertNotSame( $cookie_customer, $written_customer, 'A create must not write the preloaded cookie session.' );
+ }
+
+ /**
+ * Cancel answers with the cart as it stood, then the id stops resolving.
+ */
+ public function test_cancel_cart_returns_prior_state_then_not_found(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+
+ $cancelled = $this->request( 'POST', '/carts/' . $created['id'] . '/cancel' );
+ $data = $cancelled->get_data();
+
+ $this->assertSame( 200, $cancelled->get_status() );
+ $this->assertSame( 'success', $data['ucp']['status'] );
+ // "Business MUST return the cart state before deletion."
+ $this->assertCount( 1, $data['line_items'] );
+ $this->assertSame( 2, $data['line_items'][0]['quantity'] );
+ $this->assertSame( 'CANCELED', $data['messages'][0]['code'] );
+
+ foreach ( array( 'GET', 'PUT' ) as $method ) {
+ $after = $this->request( $method, '/carts/' . $created['id'] )->get_data();
+ $this->assertSame( 'not_found', $after['messages'][0]['code'], $method . ' on a cancelled cart' );
+ }
+
+ $again = $this->request( 'POST', '/carts/' . $created['id'] . '/cancel' )->get_data();
+ $this->assertSame( 'not_found', $again['messages'][0]['code'] );
+ }
+
+ /**
+ * A caller can still create carts after cancelling one.
+ *
+ * Creation never inherits a session, so the sticky cancellation marker on the old
+ * cart's row cannot reach the new cart.
+ */
+ public function test_a_cart_can_be_created_after_cancelling_one(): void {
+ $cancelled = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->request( 'POST', '/carts/' . $cancelled['id'] . '/cancel' );
+
+ $this->simulate_new_request();
+ $fresh = $this->create_cart( array( $this->line_item( 1, 2 ) ) );
+
+ $this->assertCount( 1, $fresh['line_items'] );
+ $this->assertSame( 2, $fresh['line_items'][0]['quantity'] );
+ $this->assertFalse( (bool) WC()->session->get( AbstractCartRoute::CANCELLED_SESSION_KEY ) );
+
+ $this->simulate_new_request( $fresh['id'] );
+ $read_back = $this->request( 'GET', '/carts/' . $fresh['id'] )->get_data();
+ $this->assertSame( $fresh['id'], $read_back['id'] );
+ $this->assertSame( $fresh['line_items'], $read_back['line_items'] );
+ }
+
+ /**
+ * A read recalculates, so totals match the prices shown.
+ *
+ * The session stores the totals from the last request; `prices` come from the live
+ * product. A price change in between would otherwise show a new unit price next to
+ * old line and cart totals.
+ */
+ public function test_reading_a_cart_recalculates_totals(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+ $this->assertSame( 3998, $this->total( $created['totals'], 'total' ) );
+
+ $product = wc_get_product( self::$product_ids[0] );
+ $product->set_regular_price( '10.00' );
+ $product->save();
+
+ try {
+ $this->simulate_new_request( $created['id'] );
+ $data = $this->request( 'GET', '/carts/' . $created['id'] )->get_data();
+ } finally {
+ $product->set_regular_price( '19.99' );
+ $product->save();
+ }
+
+ $this->assertSame( 1000, $data['line_items'][0]['item']['price'] );
+ $this->assertSame( 2000, $this->total( $data['line_items'][0]['totals'], 'subtotal' ) );
+ $this->assertSame( 2000, $this->total( $data['totals'], 'total' ) );
+ }
+
+ /**
+ * Items are named by id only; a SKU is not an identifier the catalog hands out.
+ */
+ public function test_create_cart_does_not_resolve_items_by_sku(): void {
+ $data = $this->request( 'POST', '/carts', array( 'line_items' => array( array( 'item' => array( 'id' => 'ucp-cart-mug' ) ) ) ) )->get_data();
+
+ $this->assertSame( 'error', $data['ucp']['status'] );
+ $this->assertSame( 'not_found', $data['messages'][0]['code'] );
+ $this->assertSame( '$.line_items[0].item.id', $data['messages'][0]['path'] );
+ }
+
+ /**
+ * A failure that is not a cart refusal still leaves the cart as it was.
+ *
+ * Removals run before adds, so a hook throwing during an add would otherwise leave
+ * the cart emptied of what the update was replacing.
+ */
+ public function test_update_cart_restores_contents_when_a_hook_throws(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+
+ $explode = function () {
+ throw new \RuntimeException( 'hook failure' );
+ };
+ add_action( 'woocommerce_store_api_validate_add_to_cart', $explode );
+
+ try {
+ $response = $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array( $this->line_item( 1, 1 ) ) ) );
+ } finally {
+ remove_action( 'woocommerce_store_api_validate_add_to_cart', $explode );
+ }
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'TRANSPORT_ERROR', $response->get_data()['messages'][0]['code'] );
+
+ $this->simulate_new_request( $created['id'] );
+ $data = $this->request( 'GET', '/carts/' . $created['id'] )->get_data();
+
+ $this->assertCount( 1, $data['line_items'] );
+ $this->assertSame( (string) self::$product_ids[0], $data['line_items'][0]['item']['id'] );
+ $this->assertSame( 2, $data['line_items'][0]['quantity'] );
+ }
+
+ /**
+ * Creating a cart as a logged-in user must not touch that user's own session.
+ *
+ * The token handler keys a tokenless session on the user id, so without a minted
+ * guest id the empty new session would be saved over the shopper's stored cart.
+ */
+ public function test_create_cart_while_logged_in_leaves_the_user_session_alone(): void {
+ global $wpdb;
+
+ $user_id = self::factory()->user->create( array( 'role' => 'customer' ) );
+ wp_set_current_user( $user_id );
+
+ $stored = maybe_serialize( array( 'cart' => array( 'keep-me' => array( 'quantity' => 3 ) ) ) );
+ $wpdb->insert(
+ $wpdb->prefix . 'woocommerce_sessions',
+ array(
+ 'session_key' => (string) $user_id,
+ 'session_value' => $stored,
+ 'session_expiry' => time() + DAY_IN_SECONDS,
+ )
+ );
+
+ try {
+ $this->simulate_new_request();
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->assertNotSame( (string) $user_id, CartTokenUtils::get_cart_token_payload( $created['id'] )['user_id'] );
+
+ // What shutdown would write.
+ WC()->session->save_data();
+ } finally {
+ wp_set_current_user( 0 );
+ }
+
+ $this->assertSame(
+ $stored,
+ $wpdb->get_var( $wpdb->prepare( "SELECT session_value FROM {$wpdb->prefix}woocommerce_sessions WHERE session_key = %s", (string) $user_id ) ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ );
+ }
+
+ /**
+ * A cancelled cart id stays dead on a later request.
+ *
+ * Covers what the single-process checks cannot: the marker is written to the
+ * cancelled cart's own session row, and a request carrying that id loads the row
+ * back and is refused.
+ */
+ public function test_a_cancelled_cart_id_is_still_not_found_on_a_later_request(): void {
+ $cancelled = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->request( 'POST', '/carts/' . $cancelled['id'] . '/cancel' );
+
+ // A later request with no token: a different caller, or the same one without cookies.
+ $this->simulate_new_request();
+ $fresh = $this->create_cart( array( $this->line_item( 1, 1 ) ) );
+ $this->assertNotSame( $cancelled['id'], $fresh['id'] );
+
+ // A request that does carry the cancelled id.
+ $this->simulate_new_request( $cancelled['id'] );
+
+ foreach ( array( 'GET', 'PUT' ) as $method ) {
+ $response = $this->request( $method, '/carts/' . $cancelled['id'] );
+
+ $this->assertSame( 'not_found', $response->get_data()['messages'][0]['code'], $method . ' on a cancelled cart' );
+ $this->assertArrayNotHasKey( 'line_items', $response->get_data() );
+ }
+
+ $this->assertSame(
+ 'not_found',
+ $this->request( 'POST', '/carts/' . $cancelled['id'] . '/cancel' )->get_data()['messages'][0]['code']
+ );
+ }
+
+ /**
+ * Creating a cart with a `Cart-Token` header mints a fresh session instead of
+ * binding to the named one, so a cancelled cart cannot be revived that way.
+ */
+ public function test_create_cart_ignores_a_cart_token_header(): void {
+ $cancelled = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->request( 'POST', '/carts/' . $cancelled['id'] . '/cancel' );
+
+ // As over HTTP, the bootstrap has already bound the session to the header's token.
+ $this->simulate_new_request( $cancelled['id'] );
+ $fresh = $this->create_cart( array( $this->line_item( 1, 1 ) ) );
+
+ $this->assertNotSame(
+ CartTokenUtils::get_cart_token_payload( $cancelled['id'] )['user_id'],
+ CartTokenUtils::get_cart_token_payload( $fresh['id'] )['user_id']
+ );
+
+ $this->simulate_new_request( $cancelled['id'] );
+ $this->assertSame( 'not_found', $this->request( 'GET', '/carts/' . $cancelled['id'] )->get_data()['messages'][0]['code'] );
+ }
+
+ /**
+ * Cancelling a cart must not break a storefront session cloned from it.
+ *
+ * The marker lives in the WooCommerce session, which a storefront session cloned
+ * from this token would carry too, so only the UCP routes may act on it.
+ */
+ public function test_cancelling_a_cart_leaves_the_storefront_session_usable(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->request( 'POST', '/carts/' . $created['id'] . '/cancel' );
+
+ $this->assertTrue( (bool) WC()->session->get( AbstractCartRoute::CANCELLED_SESSION_KEY ) );
+
+ $store_api = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+
+ $this->assertSame( 200, $store_api->get_status() );
+ $this->assertSame( array(), $store_api->get_data()['items'] );
+ }
+
+ /**
+ * A cart request leaves the token slot as it found it.
+ *
+ * The slot is per-process, so a route that kept its token there would bind the next
+ * dispatch in the same process — `rest_do_request()` from other code, or a test — to
+ * this cart's session.
+ */
+ public function test_a_cart_request_does_not_leak_its_token_to_the_next_dispatch(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ $this->assertSame( '', CartTokenUtils::get_request_cart_token(), 'after create' );
+
+ $this->request( 'GET', '/carts/' . $created['id'] );
+ $this->assertSame( '', CartTokenUtils::get_request_cart_token(), 'after read' );
+
+ $this->request( 'PUT', '/carts/' . $created['id'], array( 'line_items' => array( $this->line_item( 0, 2 ) ) ) );
+ $this->assertSame( '', CartTokenUtils::get_request_cart_token(), 'after update' );
+
+ // A request that arrived with its own token keeps it, rather than being cleared.
+ $_SERVER['HTTP_CART_TOKEN'] = $created['id'];
+ $this->request( 'GET', '/carts/' . $created['id'] );
+ $this->assertSame( $created['id'], CartTokenUtils::get_request_cart_token(), 'inherited token preserved' );
+ }
+
+ /**
+ * The cart id comes from the path only, never from the body or query string.
+ */
+ public function test_cart_id_is_read_from_the_path_only(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+
+ $request = new \WP_REST_Request( 'GET', '/wc/ucp/v1/carts/' . $created['id'] );
+ $request->set_query_params( array( 'id' => 'someone-elses-token' ) );
+
+ $this->assertSame( $created['id'], rest_get_server()->dispatch( $request )->get_data()['id'] );
+ }
+
+ /**
+ * Item prices are pre-tax whatever the cart display setting, so price times
+ * quantity equals the line subtotal and tax is only in the itemised totals.
+ */
+ public function test_item_price_is_pre_tax_under_inclusive_cart_display(): void {
+ update_option( 'woocommerce_calc_taxes', 'yes' );
+ update_option( 'woocommerce_tax_display_cart', 'incl' );
+ $rate_id = \WC_Tax::_insert_tax_rate(
+ array(
+ 'tax_rate' => '10.0000',
+ 'tax_rate_name' => 'UCP VAT',
+ 'tax_rate_class' => '',
+ )
+ );
+
+ try {
+ $data = $this->create_cart( array( $this->line_item( 0, 2 ) ) );
+ } finally {
+ \WC_Tax::_delete_tax_rate( $rate_id );
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ update_option( 'woocommerce_tax_display_cart', 'excl' );
+ }
+
+ // 19.99 at 10%: the price stays 19.99, the tax line carries the 10%.
+ $this->assertSame( 1999, $data['line_items'][0]['item']['price'] );
+ $this->assertSame( 3998, $this->total( $data['line_items'][0]['totals'], 'subtotal' ) );
+ $this->assertSame( 3998, $this->total( $data['totals'], 'subtotal' ) );
+ $this->assertSame( 400, $this->total( $data['totals'], 'tax' ) );
+ $this->assertSame( 4398, $this->total( $data['totals'], 'total' ) );
+ }
+
+ /**
+ * Agents compare titles literally, so the storefront HTML formatter must not
+ * reach them, the same way catalog keeps product names raw.
+ */
+ public function test_item_title_is_raw(): void {
+ $product = new \WC_Product_Simple();
+ $product->set_name( 'Frame 10x20 "Classic"' );
+ $product->set_regular_price( '7.00' );
+ $product_id = $product->save();
+
+ $data = $this->create_cart( array( array( 'item' => array( 'id' => (string) $product_id ) ) ) );
+
+ $this->assertSame( 'Frame 10x20 "Classic"', $data['line_items'][0]['item']['title'] );
+ }
+
+ /**
+ * Errors the Store API cart validation reports, such as an extension refusing the
+ * cart through `woocommerce_store_api_cart_errors`, reach the agent as recoverable
+ * messages instead of surfacing at checkout.
+ */
+ public function test_reading_a_cart_reports_store_api_cart_errors(): void {
+ $created = $this->create_cart( array( $this->line_item( 0, 1 ) ) );
+ $this->assertArrayNotHasKey( 'messages', $created );
+
+ $refuse = function ( \WP_Error $errors ) {
+ $errors->add( 'woocommerce_rest_product_partially_out_of_stock', 'Only 1 of "UCP Cart Mug" left.' );
+ };
+ add_action( 'woocommerce_store_api_cart_errors', $refuse );
+
+ try {
+ $data = $this->request( 'GET', '/carts/' . $created['id'] )->get_data();
+ } finally {
+ remove_action( 'woocommerce_store_api_cart_errors', $refuse );
+ }
+
+ $this->assertSame( 'success', $data['ucp']['status'] );
+ $this->assertCount( 1, $data['line_items'] );
+ $this->assertSame( 'out_of_stock', $data['messages'][0]['code'] );
+ $this->assertSame( 'warning', $data['messages'][0]['type'] );
+ $this->assertSame( 'recoverable', $data['messages'][0]['severity'] );
+ $this->assertSame( '$.line_items', $data['messages'][0]['path'] );
+ $this->assertSame( 'Only 1 of "UCP Cart Mug" left.', $data['messages'][0]['content'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
index c1a3f2eb58e..4072a7c373f 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
@@ -72,16 +72,9 @@ class UcpCatalog extends ControllerTestCase {
* Enable the UCP routes before the REST server is built.
*/
protected function setUp(): void {
- update_option( self::FEATURE_OPTION, 'yes' );
parent::setUp();
- }
-
- /**
- * Restore the default disabled state.
- */
- protected function tearDown(): void {
- delete_option( self::FEATURE_OPTION );
- parent::tearDown();
+ update_option( self::FEATURE_OPTION, 'yes' );
+ $this->initialize_store_api_server();
}
/**