Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions rest/python/server/cart_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""Integration tests for the UCP Cart capability."""

import asyncio
import datetime
from typing import Any
from absl.testing import absltest
from integration_test import IntegrationTest, TestCheckout
Expand Down Expand Up @@ -602,6 +603,171 @@ def test_idempotency_key_does_not_replay_across_cart_updates(self) -> None:
Cart.model_validate(response.json()).line_items[0].quantity, 1
)

def _create_cart(self, key: str) -> str:
"""Create a one-rose cart and return its id."""
response = self.client.post(
"/carts",
headers=self._get_headers(idempotency_key=key, request_id=key),
json=self._create_cart_payload([("rose", 1)]).model_dump(
mode="json", exclude_none=True
),
)
self.assertEqual(response.status_code, 201, response.text)
return response.json()["id"]

def _set_cart_expiry(self, cart_id: str, expires_at: str) -> None:
"""Overwrite a stored cart's expires_at."""

async def update() -> None:
async with self.transactions_session_factory() as session:
row = await session.get(db.CartSession, cart_id)
row.data = {**row.data, "expires_at": expires_at}
await session.commit()

asyncio.run(update())

def test_expired_cart_is_not_found(self) -> None:
"""An expired cart answers not found instead of being served.

cart.md, Get Cart: returns not_found if the cart does not exist, has
expired, or was canceled. The cart's expires_at was never checked, so a
cart past it was still returned.
"""
with self.client:
cart_id = self._create_cart("expired_get_1")
self._set_cart_expiry(cart_id, "2020-01-01T00:00:00Z")

response = self.client.get(
f"/carts/{cart_id}", headers=self._get_headers(request_id="exp_get")
)
self.assertEqual(response.status_code, 404, response.text)
error = response.json()
self.assertEqual(error["ucp"]["status"], "error")
self.assertEqual(error["messages"][0]["code"], "RESOURCE_NOT_FOUND")

def test_expired_cart_cannot_become_a_checkout(self) -> None:
"""A checkout cannot be created from a cart that has expired."""
with self.client:
cart_id = self._create_cart("expired_conv_1")
self._set_cart_expiry(cart_id, "2020-01-01T00:00:00Z")

checkout_payload = self._create_checkout_payload(
"expired_conv", [("rose", "Red Rose", 1000, 99)]
).model_dump(mode="json", exclude_none=True)
checkout_payload["cart_id"] = cart_id
response = self.client.post(
"/checkout-sessions",
headers=self._get_headers(
idempotency_key="expired_conv_2", request_id="exp_conv"
),
json=checkout_payload,
)
self.assertEqual(response.status_code, 404, response.text)
error = response.json()
self.assertEqual(error["ucp"]["status"], "error")
self.assertEqual(error["messages"][0]["code"], "RESOURCE_NOT_FOUND")

async def count_checkouts() -> int:
async with self.transactions_session_factory() as session:
return len(await db.get_checkouts_by_cart_id(session, cart_id))

self.assertEqual(asyncio.run(count_checkouts()), 0)

def test_expired_update_and_cancel_preserve_state(self) -> None:
"""Expired mutations return not found without changing stored state."""

async def stored_state(
cart_id: str, key: str
) -> tuple[dict[str, Any] | None, db.IdempotencyRecord | None]:
async with self.transactions_session_factory() as session:
return (
await db.get_cart_session(session, cart_id),
await db.get_idempotency_record(session, key),
)

with self.client:
for operation in ("update", "cancel"):
with self.subTest(operation=operation):
cart_id = self._create_cart(f"expired_{operation}_create")
self._set_cart_expiry(cart_id, "2020-01-01T00:00:00Z")
key = f"expired_{operation}_mutation"

before, _ = asyncio.run(stored_state(cart_id, key))
headers = self._get_headers(idempotency_key=key, request_id=key)
if operation == "update":
body = self._create_cart_payload([("rose", 5)]).model_dump(
mode="json", exclude_none=True
)
body["id"] = cart_id
response = self.client.put(
f"/carts/{cart_id}", headers=headers, json=body
)
else:
response = self.client.post(
f"/carts/{cart_id}/cancel", headers=headers
)

self.assertEqual(response.status_code, 404, response.text)
error = response.json()
self.assertEqual(error["ucp"]["status"], "error")
self.assertEqual(error["messages"][0]["code"], "RESOURCE_NOT_FOUND")
after, idempotency_record = asyncio.run(stored_state(cart_id, key))
self.assertEqual(after, before)
self.assertIsNone(idempotency_record)

def test_unexpired_cart_is_served_and_converted(self) -> None:
"""A cart with a future expires_at still reads and converts."""
with self.client:
cart_id = self._create_cart("unexpired_1")
self._set_cart_expiry(cart_id, "2999-01-01T00:00:00Z")

response = self.client.get(
f"/carts/{cart_id}", headers=self._get_headers(request_id="unexp_get")
)
self.assertEqual(response.status_code, 200, response.text)

checkout_payload = self._create_checkout_payload(
"unexpired_conv", [("rose", "Red Rose", 1000, 99)]
).model_dump(mode="json", exclude_none=True)
checkout_payload["cart_id"] = cart_id
response = self.client.post(
"/checkout-sessions",
headers=self._get_headers(
idempotency_key="unexpired_2", request_id="unexp_conv"
),
json=checkout_payload,
)
self.assertEqual(response.status_code, 201, response.text)

def test_cart_expiry_boundary(self) -> None:
"""A cart is expired from the instant expires_at is reached."""
from services.cart_service import is_cart_expired

now = datetime.datetime(2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
one_tick = datetime.timedelta(microseconds=1)

def cart(expires_at: datetime.datetime) -> dict[str, Any]:
return {"expires_at": expires_at.isoformat().replace("+00:00", "Z")}

self.assertTrue(is_cart_expired(cart(now - one_tick), now))
self.assertTrue(is_cart_expired(cart(now), now))
self.assertFalse(is_cart_expired(cart(now + one_tick), now))
# A cart that sets no expiry never expires.
self.assertFalse(is_cart_expired({}, now))

def test_cart_expiry_timestamp_formats(self) -> None:
"""UTC suffixes, offsets, and naive stored times share the same boundary."""
from services.cart_service import is_cart_expired

now = datetime.datetime(2026, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
for suffix, hour in (("Z", 12), ("z", 12), ("", 12), ("+02:00", 14)):
for second, expected in (("00", True), ("00.000001", False)):
expires_at = f"2026-01-01T{hour:02}:00:{second}{suffix}"
with self.subTest(expires_at=expires_at):
self.assertEqual(
is_cart_expired({"expires_at": expires_at}, now), expected
)


if __name__ == "__main__":
absltest.main()
27 changes: 24 additions & 3 deletions rest/python/server/services/cart_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@
logger = logging.getLogger(__name__)


def is_cart_expired(
cart_data: dict[str, Any], now: datetime.datetime | None = None
) -> bool:
"""Return True once a stored cart has reached its `expires_at`.

cart.md: a cart that has expired is `not_found`. A cart that sets no
expiry does not expire.
"""
expires_at = cart_data.get("expires_at")
if not expires_at:
return False
now = now or datetime.datetime.now(datetime.timezone.utc)
# Normalize RFC 3339's UTC suffixes for Python 3.10 as well.
parsed = datetime.datetime.fromisoformat(
expires_at.replace("Z", "+00:00").replace("z", "+00:00")
)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
return parsed <= now


class CartService:
"""Service for managing cart sessions."""

Expand Down Expand Up @@ -198,7 +219,7 @@ async def create_cart(
async def get_cart(self, cart_id: str) -> Cart:
"""Retrieve a cart session."""
data = await db.get_cart_session(self.transactions_session, cart_id)
if not data:
if not data or is_cart_expired(data):
raise ResourceNotFoundError(f"Cart session {cart_id} not found")
return Cart(**data)

Expand Down Expand Up @@ -229,7 +250,7 @@ async def update_cart(
existing_data = await db.get_cart_session(
self.transactions_session, cart_id
)
if not existing_data:
if not existing_data or is_cart_expired(existing_data):
raise ResourceNotFoundError(f"Cart session {cart_id} not found")
existing = Cart(**existing_data)

Expand Down Expand Up @@ -308,7 +329,7 @@ async def cancel_cart(
existing_data = await db.get_cart_session(
self.transactions_session, cart_id
)
if not existing_data:
if not existing_data or is_cart_expired(existing_data):
raise ResourceNotFoundError(f"Cart session {cart_id} not found")
cart = Cart(**existing_data)

Expand Down
3 changes: 2 additions & 1 deletion rest/python/server/services/checkout_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from models import UnifiedCheckoutUpdateRequest
from pydantic import AnyUrl
from pydantic import BaseModel
from services.cart_service import is_cart_expired
from services.fulfillment_service import FulfillmentService
from sqlalchemy.ext.asyncio import AsyncSession
import ucp_signing
Expand Down Expand Up @@ -188,7 +189,7 @@ async def create_checkout(

# Load cart to initialize checkout
cart_data = await db.get_cart_session(self.transactions_session, cart_id)
if not cart_data:
if not cart_data or is_cart_expired(cart_data):
raise ResourceNotFoundError(f"Cart session {cart_id} not found")

from models import UnifiedCart as CartModel
Expand Down
Loading