From bcc457a26086a462b851da0394296476befeccba Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Fri, 11 Sep 2026 15:33:34 -0400 Subject: [PATCH] test: cover the cart capability (cart.md) The suite has no cart coverage today. The reference server implements and advertises dev.ucp.shopping.cart, so the capability is testable, and cart.md states business obligations that no existing module asserts. This adds four assertions, each traced to a MUST: cart.md:66 cart contents win over overlapping fields in the checkout payload on conversion cart.md:72-74 a second conversion of the same cart returns the existing incomplete checkout rather than creating a new one cart.md:185 cancel returns the cart state as it was before deletion discount.md:143 discount codes applied to a cart carry forward to the checkout the cart converts into Kept server agnostic: line items are compared as a bag of item id and quantity rather than by server assigned identifiers, the conversion identity check compares checkout ids rather than totals arithmetic, both 200 and 201 are accepted where the specification does not pin a status, and the discount assertion accepts any of the three wire signals a conformant business may use. The module gates on the capability being advertised and skips honestly otherwise, since cart is not in conformance_input.json required_capabilities. Two nearby rules are deliberately not asserted. cart.md:176 (full replacement) binds the platform rather than the business, so it is not an obligation this suite can hold a merchant to. cart.md:186 (subsequent operations return not_found) is a SHOULD, so the status after cancel is not asserted. Testing. Against the reference server at the pinned ucp-sdk 0.4.6, the four assertions pass, and each was shown to fail on its own by mutating the server: taking line items from the request payload fails the first, disabling the existing checkout lookup fails the second, emptying the cart before returning it from cancel fails the third, and dropping cart discounts on conversion fails the fourth. Removing the capability from the advertised profile makes all four skip rather than fail. ruff check and ruff format are clean. --- cart_test.py | 230 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 cart_test.py diff --git a/cart_test.py b/cart_test.py new file mode 100644 index 0000000..02c2284 --- /dev/null +++ b/cart_test.py @@ -0,0 +1,230 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Conformance tests for the cart capability (cart.md). + +The suite has no cart coverage today, while the capability defines normative +business obligations that are observable on the wire. This module asserts the +ones that are stated as MUST and are not covered elsewhere: + + cart.md:66 cart contents win; overlapping checkout payload fields are + ignored on conversion + cart.md:72-74 a second conversion of the same cart returns the existing + incomplete checkout rather than creating a new one + cart.md:185 cancel returns the cart state as it was before deletion + discount.md:143 discount codes applied to a cart carry forward to the + checkout the cart converts into + +Server-agnostic by construction. Line items are compared as a bag of +(item id, quantity) rather than by server-assigned identifiers; the conversion +identity check compares checkout ids rather than totals arithmetic; both 200 and +201 are accepted where the specification does not pin a status; and the discount +assertion accepts any of the three wire signals a conformant business may use. + +Not asserted here, deliberately. cart.md:176 ("full replacement") binds the +platform rather than the business, so it is not a business obligation this suite +can hold a merchant to. cart.md:186 (subsequent operations return not_found) is +a SHOULD, so the post-cancel status is not asserted. +""" + +import uuid + +from absl.testing import absltest +import integration_test_utils + +_CART_CAPABILITY = "dev.ucp.shopping.cart" +_DISCOUNT_CAPABILITY = "dev.ucp.shopping.discount" + + +class CartTest(integration_test_utils.IntegrationTestBase): + """Cart capability conformance (cart.md).""" + + def setUp(self) -> None: + """Skip unless the business advertises the cart capability.""" + super().setUp() + self._capabilities = self._advertised_capabilities() + if _CART_CAPABILITY not in self._capabilities: + self.skipTest(f"business does not advertise {_CART_CAPABILITY}; skipping") + self._sku = self.fixture_ctx.get_test_sku() + + def _advertised_capabilities(self) -> dict: + """Return the advertised capability map, keyed by capability name.""" + resp = self.client.get("/.well-known/ucp") + self.assert_response_status(resp, 200) + ucp = resp.json().get("ucp", resp.json()) + caps = ucp.get("capabilities") or {} + if isinstance(caps, dict): + return caps + return {c.get("name"): c for c in caps if isinstance(c, dict)} + + def _discount_extends_cart(self) -> bool: + """Return True if the discount capability declares it extends cart.""" + entry = self._capabilities.get(_DISCOUNT_CAPABILITY) + if entry is None: + return False + entries = entry if isinstance(entry, list) else [entry] + for item in entries: + if not isinstance(item, dict): + continue + extends = item.get("extends") + if extends is None: + continue + if isinstance(extends, str): + extends = [extends] + if _CART_CAPABILITY in extends: + return True + return False + + # ── drivers ──────────────────────────────────────────────────────────── + def _create_cart(self, quantity: int = 2, **extra) -> dict: + """Create a cart holding the configured test item; return the raw dict.""" + payload = { + "line_items": [{"item": {"id": self._sku}, "quantity": quantity}] + } + payload.update(extra) + resp = self.client.post( + self.get_shopping_url("/carts"), + json=payload, + headers=integration_test_utils.get_headers(), + ) + self.assertIn( + resp.status_code, + (200, 201), + f"cart creation failed: {resp.status_code} {resp.text}", + ) + return resp.json() + + def _convert(self, cart_id: str, **extra) -> dict: + """Convert a cart into a checkout; return the raw checkout dict.""" + payload = {"cart_id": cart_id} + payload.update(extra) + resp = self.client.post( + self.get_shopping_url("/checkout-sessions"), + json=payload, + headers=integration_test_utils.get_headers( + idempotency_key=str(uuid.uuid4()) + ), + ) + self.assertIn( + resp.status_code, + (200, 201), + f"cart conversion failed: {resp.status_code} {resp.text}", + ) + return resp.json() + + @staticmethod + def _item_bag(raw: dict) -> list: + """Return line items as a sorted bag of (item id, quantity) pairs.""" + return sorted( + (li.get("item", {}).get("id"), li.get("quantity")) + for li in (raw.get("line_items") or []) + ) + + # ── cart.md:66 ───────────────────────────────────────────────────────── + def test_conversion_uses_cart_contents_over_payload(self) -> None: + """Cart contents win over overlapping fields in the checkout payload.""" + cart = self._create_cart(quantity=2) + conflicting = [{"item": {"id": self._sku}, "quantity": 9}] + + checkout = self._convert(cart["id"], line_items=conflicting) + + self.assertEqual( + self._item_bag(checkout), + self._item_bag(cart), + "checkout line items must come from the cart, not the request payload", + ) + + # ── cart.md:72-74 ────────────────────────────────────────────────────── + def test_second_conversion_returns_the_existing_checkout(self) -> None: + """A cart with an incomplete checkout converts to that same checkout.""" + cart = self._create_cart() + + first = self._convert(cart["id"]) + second = self._convert(cart["id"]) + + self.assertEqual( + second.get("id"), + first.get("id"), + "a second conversion must return the existing incomplete checkout", + ) + + # ── cart.md:185 ──────────────────────────────────────────────────────── + def test_cancel_returns_the_cart_state_before_deletion(self) -> None: + """Cancelling a cart returns the cart as it was before deletion.""" + cart = self._create_cart(quantity=3) + + resp = self.client.post( + self.get_shopping_url(f"/carts/{cart['id']}/cancel"), + headers=integration_test_utils.get_headers(), + ) + self.assertIn( + resp.status_code, + (200, 201), + f"cart cancel failed: {resp.status_code} {resp.text}", + ) + cancelled = resp.json() + + self.assertEqual( + cancelled.get("id"), + cart.get("id"), + "cancel must return the cancelled cart, identified by its own id", + ) + self.assertEqual( + self._item_bag(cancelled), + self._item_bag(cart), + "cancel must return the cart state as it was before deletion", + ) + + # ── discount.md:143 ──────────────────────────────────────────────────── + def test_cart_discount_codes_carry_forward_to_checkout(self) -> None: + """Discount codes applied to a cart survive conversion to a checkout.""" + if not self._discount_extends_cart(): + self.skipTest( + f"{_DISCOUNT_CAPABILITY} does not declare it extends " + f"{_CART_CAPABILITY}; skipping" + ) + code = self.fixture_ctx.get_test_discount_code() + cart = self._create_cart(discounts={"codes": [code]}) + if not self._discount_is_visible(cart, code): + self.skipTest(f"discount code {code} did not apply to the cart; skipping") + + checkout = self._convert(cart["id"]) + + self.assertTrue( + self._discount_is_visible(checkout, code), + "a discount applied to the cart must carry forward to the checkout", + ) + + @staticmethod + def _discount_is_visible(raw: dict, code: str) -> bool: + """Return True if the response shows the code, or a discount it caused.""" + discounts = raw.get("discounts") or {} + codes = [c.casefold() for c in (discounts.get("codes") or [])] + if code.casefold() in codes: + return True + applied = discounts.get("applied") or [] + if any( + (a.get("code") or "").casefold() == code.casefold() + for a in applied + if isinstance(a, dict) + ): + return True + return any( + t.get("type") == "discount" and (t.get("amount") or 0) < 0 + for t in (raw.get("totals") or []) + ) + + +if __name__ == "__main__": + absltest.main()