diff --git a/.github/workflows/conformance-tests.yml b/.github/workflows/conformance-tests.yml index cdd5424..b9b2089 100644 --- a/.github/workflows/conformance-tests.yml +++ b/.github/workflows/conformance-tests.yml @@ -75,7 +75,7 @@ jobs: uv run --directory samples/rest/python/server import_csv.py \ --products_db_path=${DATABASE_PATH}/products.db \ --transactions_db_path=${DATABASE_PATH}/transactions.db \ - --data_dir=../../../../conformance/test_data/flower_shop + --data_dir=../../../../conformance/shopping/fixtures/flower_shop - name: Start Flower Shop server run: | @@ -101,15 +101,9 @@ jobs: continue-on-error: ${{ github.event_name == 'pull_request' }} working-directory: conformance run: | - EXIT_CODE=0 - for test_file in *_test.py; do - echo "::group::${test_file}" - if ! uv run "${test_file}" \ - --server_url=http://localhost:${MERCHANT_SERVER_PORT} \ - --simulation_secret=${SIMULATION_SECRET} \ - --conformance_input=test_data/flower_shop/conformance_input.json; then - EXIT_CODE=1 - fi - echo "::endgroup::" - done - exit ${EXIT_CODE} + uv run ucp-conformance \ + --server_url=http://localhost:${MERCHANT_SERVER_PORT} \ + --simulation_secret=${SIMULATION_SECRET} \ + --platform=google \ + --suite=all \ + -v diff --git a/README.md b/README.md index bf3045c..1babc50 100644 --- a/README.md +++ b/README.md @@ -14,21 +14,35 @@ limitations under the License. --> -# UCP SDK Integration Tests - -This directory contains integration tests that run against a running UCP -Merchant Server instance. These tests are language-agnostic regarding the server -implementation (Python, Node.js, etc.) and verify adherence to the UCP -specification. +# UCP Conformance Test Suite + +This repository contains the official Universal Commerce Protocol (UCP) +conformance test suite. The suite validates merchant server implementations +against the UCP specification across core protocol requirements, common extensions +(payments, webhooks), and vertical-specific domains (such as retail shopping). + +## Architecture + +The test suite follows a 4-tier modular architecture per UCP RFC #520: + +- **`framework/`**: Shared testing harness decoupled from any business vertical. + Provides capability discovery, platform profile evaluation, mock webhook and + agent servers, base test cases, and capability decorators. +- **`core/`**: Agnostic protocol tests (`protocol_test`, `binding_test`, + `security_test`, generic `idempotency_test`). Can be run against any UCP server + regardless of business vertical. +- **`common/`**: Cross-cutting protocol capabilities such as webhooks + (`common/webhooks/`) and payment handlers (`common/payments/`). +- **`shopping/`**: Retail shopping domain tests organized by functional domain: + `checkout/`, `discount/`, `fulfillment/`, `order/`, and `validation/`. +- **`platforms/`**: Platform certification profiles (e.g. `google.yaml`) that + mandate required capabilities and payment handlers. ## Prerequisites -The tests assume a UCP Merchant Server is running and accessible via HTTP. The -server must be started with databases initialized using data from -`test_data/flower_shop` directory. Instructions to start the servers follow. - -NOTE: These instructions assume the commands are executed from the directory -containing this README. +The tests assume a UCP Merchant Server is running and accessible via HTTP. +For testing the shopping vertical, the server must be started with databases +initialized using data from `shopping/fixtures/flower_shop` (or `test_data/flower_shop`). ### Updating dependencies @@ -37,24 +51,24 @@ uv sync uv sync --directory ../samples/rest/python/server/ -uv sync --directory ../sdk/python/ +uv sync --directory ../python-sdk/ ``` -### Initializing the database +### Initializing the test database ```bash DATABASE_PATH=/tmp/ucp_test rm -rf ${DATABASE_PATH} -mkdir ${DATABASE_PATH} +mkdir -p ${DATABASE_PATH} uv run --directory ../samples/rest/python/server import_csv.py \ --products_db_path=${DATABASE_PATH}/products.db \ --transactions_db_path=${DATABASE_PATH}/transactions.db \ - --data_dir=../../../../conformance/test_data/flower_shop + --data_dir=../../../../conformance/shopping/fixtures/flower_shop ``` -Starting the server: +### Starting the server ```bash SIMULATION_SECRET=super-secret-sim-key @@ -68,17 +82,51 @@ uv run --directory ../samples/rest/python/server server.py \ MERCHANT_SERVER_PID=$! ``` -## Running the Tests +## Running the Conformance Tests + +Use the `ucp-conformance` CLI orchestrator (or `uv run runner.py`): ```bash -for test_file in *_test.py; do -uv run ${test_file} \ +# Run all discovered tests +uv run ucp-conformance \ + --server_url=http://localhost:${MERCHANT_SERVER_PORT} \ + --simulation_secret=${SIMULATION_SECRET} + +# Run only agnostic core protocol tests (zero retail dependencies) +uv run ucp-conformance --suite=core --server_url=http://localhost:${MERCHANT_SERVER_PORT} + +# Run retail shopping tests +uv run ucp-conformance --suite=shopping --server_url=http://localhost:${MERCHANT_SERVER_PORT} + +# Certify server compliance against a platform profile (e.g., Google) +uv run ucp-conformance \ + --platform=google \ --server_url=http://localhost:${MERCHANT_SERVER_PORT} \ - --simulation_secret=${SIMULATION_SECRET} \ - --conformance_input=test_data/flower_shop/conformance_input.json -done + --simulation_secret=${SIMULATION_SECRET} + +# Dry run to preview test execution list +uv run ucp-conformance --suite=all --dry-run ``` +### CLI Options + +| Option | Default | Description | +| --------------------- | ----------------------- | -------------------------------------------------------------- | +| `--server_url` | `http://localhost:8182` | Base URL of the target UCP server. | +| `--platform` | `None` | Platform profile to validate compliance against (`google`). | +| `--suite` | `all` | Test suite(s) to execute: `all`, `core`, `common`, `shopping`. | +| `--simulation_secret` | `""` | Secret for simulation endpoints. | +| `--conformance_input` | `None` | Optional path to custom conformance input JSON. | +| `-k`, `--filter` | `None` | Filter test cases matching a pattern. | +| `-v`, `--verbose` | `False` | Enable verbose execution logs. | +| `--dry-run` | `False` | List matching tests without executing. | + +### Customizing Test Fixtures + +Shopping test fixtures (SKU, expected pricing, discount codes, shipping destinations) +can be customized in `shopping/fixtures/flower_shop/test_fixtures.json` or by passing +a custom configuration file using `--fixture_config`. + ## Cleaning Up Terminate the server using: diff --git a/binding_test.py b/binding_test.py deleted file mode 100644 index 9af67f3..0000000 --- a/binding_test.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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. - -"""Tests for Token Binding in UCP SDK Server.""" - -from absl.testing import absltest -import integration_test_utils -from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) - - -# Rebuild models to resolve forward references -checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) - - -class TokenBindingTest(integration_test_utils.IntegrationTestBase): - """Tests for Token Binding. - - Validated Paths: - - POST /checkout-sessions/{id}/complete - """ - - def test_token_binding_completion(self) -> None: - """Test successful checkout completion with bound token. - - Given a ready-to-complete checkout session, - When a completion request is made using a token with binding data, - Then the request should succeed with status 200. - """ - response_json = self.create_checkout_session() - checkout_id = checkout.Checkout(**response_json).id - - payment_payload = { - "payment": { - "instruments": [ - { - "id": "instr_1", - "handler_id": "mock_payment_handler", - "type": "card", - "display": { - "brand": "visa", - "last_digits": "4242", - }, - "credential": { - "type": "token", - "token": "success_token", - "binding": { - "checkout_id": checkout_id, - "identity": {"access_token": "user_access_token"}, - }, - }, - } - ] - }, - "risk_signals": {}, - } - - response = self.client.post( - self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), - json=payment_payload, - headers=integration_test_utils.get_headers(), - ) - - self.assert_response_status(response, 200) - self.assertEqual( - response.json().get("status"), - "completed", - msg="Checkout status not 'completed'", - ) - - -if __name__ == "__main__": - absltest.main() diff --git a/business_logic_test.py b/business_logic_test.py deleted file mode 100644 index 3b892e5..0000000 --- a/business_logic_test.py +++ /dev/null @@ -1,531 +0,0 @@ -# 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. - -"""Business Logic tests for the UCP SDK Server.""" - -from absl.testing import absltest -import integration_test_utils -from ucp_sdk.models.schemas.shopping import buyer_consent as buyer_consent -from ucp_sdk.models.schemas.shopping import ( - checkout_update_request as checkout_update_req, -) -from ucp_sdk.models.schemas.shopping import discount as discount -from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping import payment_update_request -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) -from ucp_sdk.models.schemas.shopping.types import buyer_update_request -from ucp_sdk.models.schemas.shopping.types import item_update_request -from ucp_sdk.models.schemas.shopping.types import line_item_update_request - -# Rebuild models to resolve forward references -checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) - - -class BusinessLogicTest(integration_test_utils.IntegrationTestBase): - """Tests for business logic and calculations. - - Validated Paths: - - POST /checkout-sessions - - PUT /checkout-sessions/{id} - - GET /checkout-sessions/{id} - """ - - def test_totals_calculation_on_create(self): - """Test that totals are calculated correctly upon checkout creation. - - Given a request to create a checkout session with a specific item, - When the checkout is created with an incorrect title/price in the request, - Then the server should return a checkout where line item totals, subtotal, - and grand total correctly reflect the database price, ignoring client - input. - """ - # Get expected item details from config - default_item = ( - self.conformance_config.get("items", [{}])[0] - if self.conformance_config - else {} - ) - expected_price = int(default_item.get("price", 3500)) - - # Create checkout (client cannot send title/price per schema). The server - # should use the authoritative price from its DB (which matches our config). - response_json = self.create_checkout_session(select_fulfillment=False) - checkout_obj = checkout.Checkout(**response_json) - - # Verify Line Item Calculations - line_item = checkout_obj.line_items[0] - li_subtotal = next( - (t.amount for t in line_item.totals if t.type == "subtotal"), 0 - ) - li_total = next( - (t.amount for t in line_item.totals if t.type == "total"), 0 - ) - - self.assertEqual( - li_subtotal, - expected_price, - f"Line item subtotal should match DB price {expected_price}", - ) - self.assertEqual( - li_total, - expected_price, - f"Line item total should match DB price {expected_price}", - ) - - # Verify Totals Breakdown - subtotal = next( - (t for t in checkout_obj.totals if t.type == "subtotal"), None - ) - total_obj = next( - (t for t in checkout_obj.totals if t.type == "total"), None - ) - - self.assertIsNotNone(subtotal, "Subtotal missing") - self.assertEqual( - subtotal.amount, - expected_price, - f"Subtotal amount should match DB price {expected_price}", - ) - - self.assertIsNotNone(total_obj, "Total missing") - self.assertEqual( - total_obj.amount, - expected_price, - f"Total amount should match DB price {expected_price}", - ) - - def test_totals_recalculation_on_update(self): - """Test that totals are recalculated correctly upon checkout update. - - Given an existing checkout session with 1 item, - When the line item quantity is updated to 2, - Then the server should return the updated checkout with a total amount of - 2 * price. - """ - response_json = self.create_checkout_session(select_fulfillment=False) - checkout_obj = checkout.Checkout(**response_json) - checkout_id = checkout_obj.id - - # Get expected price from config - expected_price = ( - self.conformance_config.get("items", [{}])[0].get("price", 3500) - if self.conformance_config - else 3500 - ) - expected_price = int(expected_price) - - # Update quantity to 2. Total should be 2 * expected_price. - item_update = item_update_request.ItemUpdateRequest( - id=checkout_obj.line_items[0].item.id, - ) - line_item_update = line_item_update_request.LineItemUpdateRequest( - id=checkout_obj.line_items[0].id, - item=item_update, - quantity=2, - ) - payment_update = payment_update_request.PaymentUpdateRequest( - instruments=checkout_obj.payment.instruments, - ) - - update_payload = checkout_update_req.CheckoutUpdateRequest( - id=checkout_id, - currency=checkout_obj.currency, - line_items=[line_item_update], - payment=payment_update, - ) - - response = self.client.put( - self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), - json=update_payload.model_dump( - mode="json", by_alias=True, exclude_none=True - ), - headers=integration_test_utils.get_headers(), - ) - self.assert_response_status(response, 200) - - updated_checkout = checkout.Checkout(**response.json()) - total_obj = next( - (t for t in updated_checkout.totals if t.type == "total"), None - ) - expected_total = expected_price * 2 - self.assertEqual( - total_obj.amount, - expected_total, - msg=( - "Server did not correct totals on update. Expected" - f" {expected_total}, got {total_obj.amount}" - ), - ) - - def test_discount_flow(self): - """Test that valid discount codes decrease the total amount. - - Given an existing checkout session with a total amount, - When the valid discount code '10OFF' is applied, - Then the total amount should be reduced by 10%, and the - applied discount details should be present. - """ - response_json = self.create_checkout_session(select_fulfillment=False) - checkout_obj = checkout.Checkout(**response_json) - checkout_id = checkout_obj.id - - # Get expected price from config - expected_price = ( - self.conformance_config.get("items", [{}])[0].get("price", 3500) - if self.conformance_config - else 3500 - ) - expected_price = int(expected_price) - - # Apply Discount - item_update = item_update_request.ItemUpdateRequest( - id=checkout_obj.line_items[0].item.id, - ) - line_item_update = line_item_update_request.LineItemUpdateRequest( - id=checkout_obj.line_items[0].id, - item=item_update, - quantity=1, - ) - payment_update = payment_update_request.PaymentUpdateRequest( - instruments=checkout_obj.payment.instruments, - ) - - update_payload = checkout_update_req.CheckoutUpdateRequest( - id=checkout_id, - currency=checkout_obj.currency, - line_items=[line_item_update], - payment=payment_update, - ) - - update_dict = update_payload.model_dump( - mode="json", by_alias=True, exclude_none=True - ) - update_dict["discounts"] = {"codes": ["10OFF"]} - - response = self.client.put( - self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), - json=update_dict, - headers=integration_test_utils.get_headers(), - ) - self.assert_response_status(response, 200) - - discounted_checkout = checkout.Checkout(**response.json()) - expected_total = int(expected_price * 0.9) - - total_obj = next( - (t for t in discounted_checkout.totals if t.type == "total"), None - ) - self.assertIsNotNone(total_obj, "Total object missing") - self.assertEqual( - total_obj.amount, - expected_total, - msg=( - f"Discount not applied correctly. Expected {expected_total}, got" - f" {total_obj.amount}" - ), - ) - - # Parse discounts from extra fields - discounts_data = getattr(discounted_checkout, "discounts", {}) - discounts_obj = ( - discount.DiscountsObject(**discounts_data) if discounts_data else None - ) - - self.assertTrue( - discounts_obj and discounts_obj.applied, - "Applied discounts field missing", - ) - self.assertEqual( - discounts_obj.applied[0].code, - "10OFF", - "Applied discounts field incorrect", - ) - - def test_multiple_discounts_accepted(self): - """Test that multiple valid discount codes are both applied. - - Given an existing checkout session, - When two valid discount codes ('10OFF' and 'WELCOME20') are applied, - Then the total amount should be reduced by both discounts sequentially, - and both should be present in the applied list. - """ - response_json = self.create_checkout_session(select_fulfillment=False) - checkout_obj = checkout.Checkout(**response_json) - - # Get expected price from config - expected_price = ( - self.conformance_config.get("items", [{}])[0].get("price", 3500) - if self.conformance_config - else 3500 - ) - expected_price = int(expected_price) - - # Apply both discounts using helper to ensure all required fields are - # present - response_json = self.update_checkout_session( - checkout_obj, discounts={"codes": ["10OFF", "WELCOME20"]} - ) - - discounted_checkout = checkout.Checkout(**response_json) - # 3500 -> 3500 * 0.9 = 3150 -> 3150 * 0.8 = 2520 - expected_total = int(int(expected_price * 0.9) * 0.8) - - total_obj = next( - (t for t in discounted_checkout.totals if t.type == "total"), None - ) - self.assertEqual( - total_obj.amount, - expected_total, - f"Multiple discounts failed. Exp {expected_total}, " - f"got {total_obj.amount}", - ) - - # Verify both applied discounts are present - discounts_data = getattr(discounted_checkout, "discounts", {}) - discounts_obj = ( - discount.DiscountsObject(**discounts_data) if discounts_data else None - ) - self.assertTrue(discounts_obj and len(discounts_obj.applied) == 2) - applied_codes = [d.code for d in discounts_obj.applied] - self.assertIn("10OFF", applied_codes) - self.assertIn("WELCOME20", applied_codes) - - def test_multiple_discounts_one_rejected(self): - """Test requesting multiple discounts where one is valid and one is not. - - Given an existing checkout session, - When one valid ('10OFF') and one invalid ('INVALID_CODE') are applied, - Then only the valid discount should be applied, and the invalid one - should be omitted from the applied list. - """ - response_json = self.create_checkout_session(select_fulfillment=False) - checkout_obj = checkout.Checkout(**response_json) - - # Get expected price - expected_price = ( - self.conformance_config.get("items", [{}])[0].get("price", 3500) - if self.conformance_config - else 3500 - ) - expected_price = int(expected_price) - - # Apply one valid and one invalid discount using helper - response_json = self.update_checkout_session( - checkout_obj, discounts={"codes": ["10OFF", "INVALID_CODE"]} - ) - - discounted_checkout = checkout.Checkout(**response_json) - # Only 10% off - expected_total = int(expected_price * 0.9) - - total_obj = next( - (t for t in discounted_checkout.totals if t.type == "total"), None - ) - self.assertEqual(total_obj.amount, expected_total) - - # Verify only one applied discount is present - discounts_data = getattr(discounted_checkout, "discounts", {}) - discounts_obj = ( - discount.DiscountsObject(**discounts_data) if discounts_data else None - ) - self.assertTrue(discounts_obj and len(discounts_obj.applied) == 1) - self.assertEqual(discounts_obj.applied[0].code, "10OFF") - - def test_fixed_amount_discount(self): - """Test that a fixed-amount discount code decreases the total correctly. - - Given an existing checkout session with a total amount, - When the valid fixed-amount discount code 'FIXED500' is applied, - Then the total amount should be reduced by 500 cents, and the - applied discount details should be present. - """ - response_json = self.create_checkout_session(select_fulfillment=False) - checkout_obj = checkout.Checkout(**response_json) - - # Get expected price from config - expected_price = ( - self.conformance_config.get("items", [{}])[0].get("price", 3500) - if self.conformance_config - else 3500 - ) - expected_price = int(expected_price) - - # Apply Fixed-amount Discount - response_json = self.update_checkout_session( - checkout_obj, discounts={"codes": ["FIXED500"]} - ) - - discounted_checkout = checkout.Checkout(**response_json) - # 3500 - 500 = 3000 - expected_total = expected_price - 500 - - total_obj = next( - (t for t in discounted_checkout.totals if t.type == "total"), None - ) - self.assertIsNotNone(total_obj, "Total object missing") - self.assertEqual( - total_obj.amount, - expected_total, - msg=( - f"Fixed discount failed. Exp {expected_total}, got {total_obj.amount}" - ), - ) - - # Parse discounts from extra fields - discounts_data = getattr(discounted_checkout, "discounts", {}) - discounts_obj = ( - discount.DiscountsObject(**discounts_data) if discounts_data else None - ) - - self.assertTrue( - discounts_obj and discounts_obj.applied, - "Applied discounts field missing", - ) - self.assertEqual( - discounts_obj.applied[0].code, - "FIXED500", - ) - self.assertEqual( - discounts_obj.applied[0].amount, - 500, - ) - - def test_buyer_consent(self): - """Test that buyer consent preferences are persisted on creation. - - Given a checkout creation payload including buyer consent preferences - (marketing=True, analytics=False), - When the checkout session is created, - Then the returned checkout object should correctly reflect these consent - values. - """ - create_payload = self.create_checkout_payload() - - # Add consent info - consent_obj = buyer_consent.Consent( - marketing=True, - analytics=False, - sale_of_data=False, - ) - - create_payload_dict = create_payload.model_dump( - mode="json", by_alias=True, exclude_none=True - ) - create_payload_dict["buyer"] = { - "first_name": "Consent", - "last_name": "Tester", - "email": "consent@example.com", - "consent": consent_obj.model_dump( - mode="json", by_alias=True, exclude_none=True - ), - } - - response = self.client.post( - self.get_shopping_url("/checkout-sessions"), - json=create_payload_dict, - headers=integration_test_utils.get_headers(), - ) - self.assert_response_status(response, 201) - checkout_id = checkout.Checkout(**response.json()).id - - response = self.client.get( - self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), - headers=integration_test_utils.get_headers(), - ) - self.assert_response_status(response, 200) - - checkout_obj = checkout.Checkout(**response.json()) - self.assertTrue(checkout_obj.buyer, "Buyer info missing") - - # buyer is types.buyer.Buyer, consent is in extra fields - consent_data = getattr(checkout_obj.buyer, "consent", None) - self.assertTrue(consent_data, "Consent info missing") - - # Parse to model for easy access - consent_model = buyer_consent.Consent(**consent_data) - - self.assertTrue( - consent_model.marketing, - f"Marketing consent not persisted. Resp: {consent_model}", - ) - self.assertFalse( - consent_model.analytics, - f"Analytics consent not persisted. Resp: {consent_model}", - ) - - def test_buyer_info_persistence(self): - """Test that buyer information is persisted on update. - - Given an existing checkout session, - When the session is updated with new buyer details (email, name), - Then the retrieved checkout session should reflect these updated buyer - details. - """ - response_json = self.create_checkout_session(select_fulfillment=False) - checkout_obj = checkout.Checkout(**response_json) - checkout_id = checkout_obj.id - - # Update with buyer info - item_update = item_update_request.ItemUpdateRequest( - id=checkout_obj.line_items[0].item.id, - ) - line_item_update = line_item_update_request.LineItemUpdateRequest( - id=checkout_obj.line_items[0].id, - item=item_update, - quantity=1, - ) - payment_update = payment_update_request.PaymentUpdateRequest( - instruments=checkout_obj.payment.instruments, - ) - - update_payload = checkout_update_req.CheckoutUpdateRequest( - id=checkout_id, - currency=checkout_obj.currency, - line_items=[line_item_update], - payment=payment_update, - buyer=buyer_update_request.BuyerUpdateRequest( - email="test@example.com", - first_name="Test", - last_name="User", - ), - ) - - response = self.client.put( - self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), - json=update_payload.model_dump( - mode="json", by_alias=True, exclude_none=True - ), - headers=integration_test_utils.get_headers(), - ) - self.assert_response_status(response, 200) - - # GET and verify - response = self.client.get( - self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), - headers=integration_test_utils.get_headers(), - ) - checkout_obj = checkout.Checkout(**response.json()) - self.assertTrue(checkout_obj.buyer, "Buyer info missing") - self.assertEqual( - checkout_obj.buyer.email, "test@example.com", "Email mismatch" - ) - self.assertEqual( - checkout_obj.buyer.first_name, "Test", "First name mismatch" - ) - - -if __name__ == "__main__": - absltest.main() diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..43384ee --- /dev/null +++ b/common/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Cross-vertical capability test suites (webhooks, payments).""" diff --git a/common/payments/__init__.py b/common/payments/__init__.py new file mode 100644 index 0000000..961bcb5 --- /dev/null +++ b/common/payments/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Payment capability test suites (card credential, AP2 mandates).""" diff --git a/ap2_test.py b/common/payments/ap2_test.py similarity index 90% rename from ap2_test.py rename to common/payments/ap2_test.py index ded8ac0..08e4579 100644 --- a/ap2_test.py +++ b/common/payments/ap2_test.py @@ -15,17 +15,25 @@ """Tests for AP2 Mandate in UCP SDK Server.""" from absl.testing import absltest +from framework.decorators import requires_capability import integration_test_utils from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) # Rebuild models to resolve forward references checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) +@requires_capability("dev.ucp.common.payments.ap2") class Ap2MandateTest(integration_test_utils.IntegrationTestBase): """Tests for AP2 Mandate. diff --git a/card_credential_test.py b/common/payments/card_credential_test.py similarity index 89% rename from card_credential_test.py rename to common/payments/card_credential_test.py index c9496e3..ce967aa 100644 --- a/card_credential_test.py +++ b/common/payments/card_credential_test.py @@ -15,17 +15,25 @@ """Tests for Card Credential in UCP SDK Server.""" from absl.testing import absltest +from framework.decorators import requires_capability import integration_test_utils from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) # Rebuild models to resolve forward references checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) +@requires_capability("dev.ucp.common.payments.cards") class CardCredentialTest(integration_test_utils.IntegrationTestBase): """Tests for Card Credential. diff --git a/common/webhooks/__init__.py b/common/webhooks/__init__.py new file mode 100644 index 0000000..b26a68f --- /dev/null +++ b/common/webhooks/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Webhook capability test suites.""" diff --git a/webhook_test.py b/common/webhooks/webhook_test.py similarity index 96% rename from webhook_test.py rename to common/webhooks/webhook_test.py index a72b0d7..96685bd 100644 --- a/webhook_test.py +++ b/common/webhooks/webhook_test.py @@ -16,16 +16,25 @@ import time from absl.testing import absltest +from framework.decorators import requires_capability +from framework.mock_webhook_server import MockWebhookServer import integration_test_utils from ucp_sdk.models.schemas.shopping import checkout -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) # Rebuild models to resolve forward references checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) +@requires_capability("dev.ucp.common.webhooks") class WebhookTest(integration_test_utils.IntegrationTestBase): """Tests for Webhook notifications.""" @@ -33,7 +42,7 @@ def setUp(self) -> None: """Set up the webhook server and configuration.""" super().setUp() port = integration_test_utils.FLAGS.mock_webhook_port - self.webhook_server = integration_test_utils.MockWebhookServer(port=port) + self.webhook_server = MockWebhookServer(port=port) self.webhook_server.start() self.webhook_url = ( f"http://localhost:{port}/webhooks/partners/test_partner/events/order" diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..3da8eb0 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,18 @@ +# 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. + +"""Core UCP protocol conformance tests. + +Covers transport, discovery, idempotency, and security. +""" diff --git a/core/binding_test.py b/core/binding_test.py new file mode 100644 index 0000000..a6b54a8 --- /dev/null +++ b/core/binding_test.py @@ -0,0 +1,60 @@ +# 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. + +"""Tests for HTTP/JSON envelope and protocol binding verification.""" + +from absl.testing import absltest +from framework.base_test import BaseIntegrationTest + + +class ProtocolBindingTest(BaseIntegrationTest): + """Tests for HTTP/JSON envelope and protocol binding compliance. + + Validated Paths: + - GET /.well-known/ucp + """ + + def test_protocol_envelope_headers(self) -> None: + """Test that standard UCP transport headers are accepted and processed. + + Given a discovery request, + When standard UCP headers (UCP-Agent, idempotency-key, request-id) are sent, + Then the response returns 200 OK with application/json Content-Type. + """ + headers = self.get_headers() + response = self.client.get("/.well-known/ucp", headers=headers) + self.assert_response_status(response, 200) + content_type = response.headers.get("content-type", "") + self.assertIn("application/json", content_type.lower()) + + def test_invalid_content_type_rejected(self) -> None: + """Test that POST requests with unsupported Content-Type return 400 or 415. + + When sending non-JSON payloads to protocol endpoints, + Then the server should reject the request. + """ + headers = self.get_headers() + headers["Content-Type"] = "text/plain" + # POST with non-JSON content type to well-known or discovery + response = self.client.post( + "/.well-known/ucp", + content="plain text body", + headers=headers, + ) + # Server should return 405 (Method Not Allowed), 415, or 400 + self.assertIn(response.status_code, [400, 404, 405, 415]) + + +if __name__ == "__main__": + absltest.main() diff --git a/idempotency_test.py b/core/idempotency_test.py similarity index 89% rename from idempotency_test.py rename to core/idempotency_test.py index a1b8fb3..30ced8a 100644 --- a/idempotency_test.py +++ b/core/idempotency_test.py @@ -12,30 +12,43 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Idempotency tests for the UCP SDK Server.""" +"""Idempotency tests for UCP protocol servers.""" import uuid - from absl.testing import absltest +from framework.base_test import BaseIntegrationTest +from framework.decorators import requires_capability import integration_test_utils from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) # Rebuild models to resolve forward references checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) -class IdempotencyTest(integration_test_utils.IntegrationTestBase): - """Tests for API idempotency. +class GenericIdempotencyTest(BaseIntegrationTest): + """Agnostic transport tests for Idempotency-Key headers.""" + + def test_idempotency_header_accepted(self) -> None: + """Test that protocol endpoints accept idempotency-key header.""" + headers = self.get_headers(idempotency_key=str(uuid.uuid4())) + response = self.client.get("/.well-known/ucp", headers=headers) + self.assert_response_status(response, 200) + - Validated Paths: - - POST /checkout-sessions - - PUT /checkout-sessions/{id} - - POST /checkout-sessions/{id}/complete - - POST /checkout-sessions/{id}/cancel - """ +@requires_capability("dev.ucp.shopping.checkout") +class ShoppingVehicleIdempotencyTest( + integration_test_utils.IntegrationTestBase +): + """Tests for state-mutating idempotency using checkout sessions.""" def test_idempotency_create(self) -> None: """Test that checkout creation is idempotent. diff --git a/core/protocol_test.py b/core/protocol_test.py new file mode 100644 index 0000000..3c4b877 --- /dev/null +++ b/core/protocol_test.py @@ -0,0 +1,128 @@ +# 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. + +"""Generic protocol and discovery compliance tests for UCP servers.""" + +import re +from absl.testing import absltest +from framework.base_test import BaseIntegrationTest +from ucp_sdk.models.schemas.ucp import BusinessSchema + +REVERSE_DNS_REGEX = re.compile(r"^[a-z0-9_]+(\.[a-z0-9_]+)+$") +VERSION_DATE_REGEX = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +class ProtocolTest(BaseIntegrationTest): + """Tests for generic UCP protocol and discovery compliance. + + Validated Paths: + - GET /.well-known/ucp + """ + + def test_discovery_schema_and_types(self): + """Test GET /.well-known/ucp returns 200 and conforms to UCP schema.""" + response = self.client.get("/.well-known/ucp") + self.assert_response_status(response, 200) + data = response.json() + + # Discovery profile may be at root or under 'ucp' + ucp_data = data.get("ucp", data) + self.assertIn( + "version", ucp_data, "Discovery profile must contain 'version'" + ) + + # Validate against UCP SDK BusinessSchema model + BusinessSchema(**ucp_data) + + # Validate UCP root version format + version_val = ucp_data.get("version") + version_str = ( + version_val.get("root", "") + if isinstance(version_val, dict) + else str(version_val) + ) + self.assertRegex( + version_str, + VERSION_DATE_REGEX, + f"UCP root version '{version_str}' is not in YYYY-MM-DD format.", + ) + + # Validate capability reverse-DNS naming and version formats + caps_data = ucp_data.get("capabilities", {}) + if isinstance(caps_data, dict): + for cap_name, cap_list in caps_data.items(): + self.assertRegex( + cap_name, + REVERSE_DNS_REGEX, + f"Capability '{cap_name}' does not follow reverse-DNS naming.", + ) + items = cap_list if isinstance(cap_list, list) else [cap_list] + for cap in items: + if isinstance(cap, dict) and "version" in cap: + ver = cap["version"] + ver_str = ver.get("root", "") if isinstance(ver, dict) else str(ver) + self.assertRegex( + ver_str, + VERSION_DATE_REGEX, + f"Capability '{cap_name}' version '{ver_str}' not YYYY-MM-DD.", + ) + elif isinstance(caps_data, list): + for cap in caps_data: + if isinstance(cap, dict) and "name" in cap: + self.assertRegex( + cap["name"], + REVERSE_DNS_REGEX, + f"Capability '{cap['name']}' does not follow reverse-DNS naming.", + ) + if "version" in cap: + ver = cap["version"] + ver_str = ver.get("root", "") if isinstance(ver, dict) else str(ver) + self.assertRegex( + ver_str, + VERSION_DATE_REGEX, + f"Capability '{cap['name']}' version '{ver_str}' not YYYY-MM-DD.", + ) + + # Validate payment handlers reverse-DNS naming if present + payment_handlers = ucp_data.get("payment_handlers", {}) + if isinstance(payment_handlers, dict): + for group_name, handlers in payment_handlers.items(): + self.assertRegex( + group_name, + REVERSE_DNS_REGEX, + f"Payment handler group '{group_name}' not in reverse-DNS format.", + ) + items = handlers if isinstance(handlers, list) else [handlers] + for handler in items: + if isinstance(handler, dict): + self.assertTrue(handler.get("id"), "Payment handler missing 'id'") + + def test_version_negotiation(self): + """Test protocol version negotiation via UCP-Agent header.""" + headers = self.get_headers() + + # 1. Compatible version request to discovery + headers["UCP-Agent"] = 'profile="..."; version="2026-04-08"' + resp = self.client.get("/.well-known/ucp", headers=headers) + self.assert_response_status(resp, 200) + + # 2. Future / incompatible version request + headers["UCP-Agent"] = 'profile="..."; version="2099-01-01"' + resp = self.client.get("/.well-known/ucp", headers=headers) + # Discovery must still succeed or respond with structured error + self.assertIn(resp.status_code, [200, 400]) + + +if __name__ == "__main__": + absltest.main() diff --git a/simulation_url_security_test.py b/core/security_test.py similarity index 65% rename from simulation_url_security_test.py rename to core/security_test.py index 1d5e438..d78e65e 100644 --- a/simulation_url_security_test.py +++ b/core/security_test.py @@ -12,47 +12,46 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for security controls in UCP SDK Server.""" +"""Tests for simulation URL security and authorization headers.""" +from absl import flags from absl.testing import absltest -import integration_test_utils +from framework.base_test import BaseIntegrationTest +FLAGS = flags.FLAGS -class SecurityTest(integration_test_utils.IntegrationTestBase): - """Tests for security controls.""" + +class SecurityTest(BaseIntegrationTest): + """Tests for simulation endpoint security controls.""" def test_simulation_endpoint_missing_header(self): """Test access without the secret header returns 403.""" - order_id = self.create_completed_order() response = self.client.post( - f"/testing/simulate-shipping/{order_id}", - headers=self.get_headers(), # Standard headers only + "/testing/simulate-shipping/test-order-security", + headers=self.get_headers(), ) self.assert_response_status(response, 403) def test_simulation_endpoint_incorrect_secret(self): """Test access with an incorrect secret returns 403.""" - order_id = self.create_completed_order() headers = self.get_headers() headers["Simulation-Secret"] = "for-sure-incorrect-secret" response = self.client.post( - f"/testing/simulate-shipping/{order_id}", + "/testing/simulate-shipping/test-order-security", headers=headers, ) self.assert_response_status(response, 403) def test_simulation_endpoint_correct_secret(self): - """Test access with the correct secret returns 200.""" - order_id = self.create_completed_order() + """Test access with the correct secret bypasses 403 security block.""" headers = self.get_headers() - headers["Simulation-Secret"] = ( - integration_test_utils.FLAGS.simulation_secret - ) + headers["Simulation-Secret"] = FLAGS.simulation_secret response = self.client.post( - f"/testing/simulate-shipping/{order_id}", + "/testing/simulate-shipping/test-order-security", headers=headers, ) - self.assert_response_status(response, 200) + # Valid secret bypasses security check (returns 404 for test ID or 200) + self.assertIn(response.status_code, [200, 404]) if __name__ == "__main__": diff --git a/framework/__init__.py b/framework/__init__.py new file mode 100644 index 0000000..6aff1bd --- /dev/null +++ b/framework/__init__.py @@ -0,0 +1,35 @@ +# 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. + +"""UCP Conformance Framework: Core test runtime and SDK-agnostic base.""" + +from framework.agent_profile_server import AgentProfileServer +from framework.base_test import BaseIntegrationTest +from framework.decorators import requires_capability, spec_assert +from framework.discovery import Capability, UcpProfile, fetch_server_profile +from framework.mock_webhook_server import MockWebhookServer +from framework.platform import PlatformProfile, PlatformRequirement + +__all__ = [ + "AgentProfileServer", + "BaseIntegrationTest", + "Capability", + "MockWebhookServer", + "PlatformProfile", + "PlatformRequirement", + "UcpProfile", + "fetch_server_profile", + "requires_capability", + "spec_assert", +] diff --git a/framework/agent_profile_server.py b/framework/agent_profile_server.py new file mode 100644 index 0000000..8d5df03 --- /dev/null +++ b/framework/agent_profile_server.py @@ -0,0 +1,127 @@ +# 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. + +"""Background mock agent server serving agent profile configurations.""" + +import json +from pathlib import Path +import threading +import time +from typing import Any +from fastapi import FastAPI +from fastapi.responses import JSONResponse +import httpx +import uvicorn + +DEFAULT_PROFILE = { + "agent": { + "id": "mock-test-agent", + "name": "Conformance Test Agent", + "version": "1.0.0", + }, + "endpoints": { + "webhook": "http://localhost:{webhook_port}/webhooks/test", + }, +} + + +class AgentProfileServer: + """Serves a test agent profile with dynamic port resolution.""" + + PROFILE_PATH = "/profiles/agent.json" + LEGACY_PROFILE_PATH = "/profiles/shopping-agent.json" + + def __init__( + self, + *, + port: int, + webhook_port: int, + profile_template_path: str | None = None, + profile_dict: dict[str, Any] | None = None, + ): + """Initialize AgentProfileServer with port and profile configuration. + + Args: + port: The HTTP port to bind to. + webhook_port: The mock webhook port to interpolate into endpoints. + profile_template_path: Optional path to a JSON profile template. + profile_dict: Optional dictionary to serialize as the profile. + + """ + self.port = port + self.webhook_port = webhook_port + self.app = FastAPI() + + if profile_dict is not None: + self._profile_template = json.dumps(profile_dict) + elif profile_template_path and Path(profile_template_path).exists(): + with Path(profile_template_path).open(encoding="utf-8") as f: + self._profile_template = f.read() + else: + legacy_path = ( + Path(__file__).resolve().parent.parent / "shopping-agent-test.json" + ) + if legacy_path.exists(): + with legacy_path.open(encoding="utf-8") as f: + self._profile_template = f.read() + else: + self._profile_template = json.dumps(DEFAULT_PROFILE) + + self._setup_routes() + self._server: uvicorn.Server | None = None + self._thread: threading.Thread | None = None + + def _setup_routes(self) -> None: + async def _respond_profile() -> JSONResponse: + content = self._profile_template.replace( + "{webhook_port}", str(self.webhook_port) + ) + return JSONResponse(content=json.loads(content)) + + self.app.get(self.PROFILE_PATH, response_model=None)(_respond_profile) + self.app.get(self.LEGACY_PROFILE_PATH, response_model=None)( + _respond_profile + ) + + @self.app.get("/healthz") + async def health_check() -> dict[str, str]: + return {"status": "ok"} + + def start(self) -> None: + """Start the mock agent server in a background thread.""" + config = uvicorn.Config( + self.app, host="0.0.0.0", port=self.port, log_level="error" + ) + self._server = uvicorn.Server(config) + self._thread = threading.Thread(target=self._server.run, daemon=True) + self._thread.start() + + for _ in range(50): + try: + with httpx.Client() as client: + resp = client.get(f"http://localhost:{self.port}/healthz") + if resp.status_code == 200: + return + except httpx.ConnectError: + time.sleep(0.05) + raise RuntimeError( + f"AgentProfileServer failed to start on port {self.port}" + ) + + def stop(self) -> None: + """Stop the background mock agent server.""" + if self._server is not None: + self._server.should_exit = True + if self._thread is not None: + self._thread.join(timeout=3) diff --git a/framework/base_test.py b/framework/base_test.py new file mode 100644 index 0000000..5c0facc --- /dev/null +++ b/framework/base_test.py @@ -0,0 +1,151 @@ +# 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. + +"""Base integration test class decoupled from specific business verticals.""" + +import logging +import os +import uuid +from absl import flags +from absl.testing import absltest +from framework.agent_profile_server import AgentProfileServer +from framework.discovery import UcpProfile, fetch_server_profile +import httpx + +FLAGS = flags.FLAGS +try: + flags.DEFINE_string("server_url", None, "Base URL of the target UCP server") + flags.DEFINE_string( + "simulation_secret", str(uuid.uuid4()), "Secret for simulation endpoints" + ) + flags.DEFINE_integer( + "mock_webhook_port", 8284, "Port for the mock webhook server" + ) + flags.DEFINE_integer( + "mock_agent_port", 8285, "Port for the mock agent profile server" + ) + flags.DEFINE_bool( + "verbose_http", False, "Whether to log HTTP requests and responses" + ) +except flags.DuplicateFlagError: + pass + + +class BaseIntegrationTest(absltest.TestCase): + """Agnostic foundation for all UCP protocol and capability tests.""" + + server_profile: UcpProfile | None = None + + def setUp(self) -> None: + """Set up test environment, HTTP client, and mock agent server.""" + super().setUp() + server_url = ( + FLAGS.server_url + if FLAGS.is_parsed() and FLAGS.server_url + else os.environ.get("FLAGS_server_url") # noqa: SIM112 + ) + if not server_url: + self.skipTest("Missing --server_url flag") + + self.base_url = server_url.rstrip("/") + self.client = httpx.Client(base_url=self.base_url, timeout=15.0) + + httpx_logger = logging.getLogger("httpx") + httpx_logger.setLevel( + logging.INFO + if (FLAGS.is_parsed() and FLAGS.verbose_http) + else logging.WARNING + ) + + # Lazily cache server discovery profile across tests + if BaseIntegrationTest.server_profile is None: + try: + BaseIntegrationTest.server_profile = fetch_server_profile(self.base_url) + except Exception as e: # pylint: disable=broad-exception-caught + logging.warning("Could not pre-fetch /.well-known/ucp: %s", e) + + # Capability filtering check from @requires_capability decorator + self._verify_required_capabilities() + + self._start_agent_server() + + def _verify_required_capabilities(self) -> None: + """Verify that the server supports capabilities required by class/method.""" + reqs = set() + if hasattr(self, "_required_capabilities"): + reqs.update(self._required_capabilities) + test_method = getattr(self, self._testMethodName, None) + if test_method and hasattr(test_method, "_required_capabilities"): + reqs.update(test_method._required_capabilities) + + if self.server_profile is not None: + for cap_name, min_ver in reqs: + if not self.server_profile.supports_capability(cap_name, min_ver): + self.skipTest( + f"Server does not support required capability: '{cap_name}' " + f"(min_version: {min_ver or 'any'})" + ) + + def _start_agent_server(self) -> None: + agent_port = FLAGS.mock_agent_port if FLAGS.is_parsed() else 8285 + webhook_port = FLAGS.mock_webhook_port if FLAGS.is_parsed() else 8284 + self.agent_server = AgentProfileServer( + port=agent_port, + webhook_port=webhook_port, + ) + self.agent_server.start() + + def tearDown(self) -> None: + """Tear down HTTP client and mock agent server.""" + self.client.close() + if hasattr(self, "agent_server") and self.agent_server is not None: + self.agent_server.stop() + super().tearDown() + + def get_headers( + self, + idempotency_key: str | None = None, + request_id: str | None = None, + custom_profile_url: str | None = None, + ) -> dict[str, str]: + """Generate standard UCP transport headers.""" + agent_port = FLAGS.mock_agent_port if FLAGS.is_parsed() else 8285 + profile_url = ( + custom_profile_url + or f"http://localhost:{agent_port}{AgentProfileServer.PROFILE_PATH}" + ) + return { + "UCP-Agent": f'profile="{profile_url}"', + "request-signature": "test", + "idempotency-key": idempotency_key or str(uuid.uuid4()), + "request-id": request_id or str(uuid.uuid4()), + "Content-Type": "application/json", + } + + def assert_response_status( + self, response: httpx.Response, expected_code: int | list[int] + ) -> None: + """Verify that an HTTP response has the expected status code.""" + expected = ( + [expected_code] if isinstance(expected_code, int) else expected_code + ) + self.assertIn( + response.status_code, + expected, + msg=( + f"Expected status {expected}, got {response.status_code} for" + f" {response.request.method} {response.request.url}. Response:" + f" {response.text}" + ), + ) diff --git a/framework/decorators.py b/framework/decorators.py new file mode 100644 index 0000000..33f5e5c --- /dev/null +++ b/framework/decorators.py @@ -0,0 +1,73 @@ +# 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. + +"""Decorators for capability filtering and specification traceability.""" + +from collections.abc import Callable +import functools +from typing import TypeVar + +T = TypeVar("T") + + +def requires_capability( + capability_name: str, min_version: str | None = None +) -> Callable[[T], T]: + """Mark a test class or test method as requiring a specific UCP capability. + + If the server under test does not advertise the capability, skip the test. + + Args: + capability_name: The name of the capability. + min_version: Optional minimum version string. + + Returns: + The decorated class or method. + + """ + + def decorator(obj: T) -> T: + if not hasattr(obj, "_required_capabilities"): + obj._required_capabilities = set() + obj._required_capabilities.add((capability_name, min_version)) + return obj + + return decorator + + +def spec_assert(section: str, requirement_id: str) -> Callable: + """Tag a test method with a specific clause from the UCP specification. + + Used for automated compliance matrix generation and test auditing. + + Args: + section: The specification section identifier. + requirement_id: The specific requirement ID. + + Returns: + The decorated test method. + + """ + + def decorator(func: Callable) -> Callable: + func._spec_section = section + func._spec_requirement_id = requirement_id + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/framework/discovery.py b/framework/discovery.py new file mode 100644 index 0000000..7303877 --- /dev/null +++ b/framework/discovery.py @@ -0,0 +1,141 @@ +# 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. + +"""Discovery and capability inspection for UCP servers.""" + +from dataclasses import dataclass +from typing import Any +import httpx + + +@dataclass(frozen=True) +class Capability: + """Represents a discovered capability.""" + + name: str + version: str + spec: str | None = None + schema_url: str | None = None + + +class UcpProfile: + """Represents an advertised server profile from /.well-known/ucp.""" + + def __init__(self, raw_data: dict[str, Any]): + """Initialize UcpProfile from raw discovery JSON.""" + self.raw = raw_data + ucp_section = ( + raw_data.get("ucp") if isinstance(raw_data.get("ucp"), dict) else raw_data + ) + + # Extract version + version_val = ucp_section.get("version", "") + if isinstance(version_val, dict): + self.ucp_version: str = str(version_val.get("root", "")) + else: + self.ucp_version: str = str(version_val) + + # Extract capabilities + self.capabilities: dict[str, Capability] = {} + caps_data = ucp_section.get("capabilities", {}) + + if isinstance(caps_data, list): + for cap in caps_data: + if isinstance(cap, dict): + name = cap.get("name") + if name: + ver = cap.get("version", "") + ver_str = ver.get("root", "") if isinstance(ver, dict) else str(ver) + self.capabilities[name] = Capability( + name=name, + version=ver_str, + spec=cap.get("spec"), + schema_url=cap.get("schema"), + ) + elif isinstance(caps_data, dict): + for name, cap_list in caps_data.items(): + items = cap_list if isinstance(cap_list, list) else [cap_list] + for cap in items: + if isinstance(cap, dict): + ver = cap.get("version", "") + ver_str = ver.get("root", "") if isinstance(ver, dict) else str(ver) + self.capabilities[name] = Capability( + name=name, + version=ver_str, + spec=cap.get("spec"), + schema_url=cap.get("schema"), + ) + + # Extract payment handlers + self.payment_handlers: set[str] = set() + + handlers_data = ( + ucp_section.get("payment_handlers") + or ucp_section.get("payment", {}).get("handlers") + or raw_data.get("payment_handlers") + or raw_data.get("payment", {}).get("handlers", {}) + ) + + if isinstance(handlers_data, list): + for h in handlers_data: + if isinstance(h, dict): + if h.get("id"): + self.payment_handlers.add(str(h["id"])) + if h.get("name"): + self.payment_handlers.add(str(h["name"])) + elif isinstance(handlers_data, dict): + for key, h_list in handlers_data.items(): + self.payment_handlers.add(str(key)) + items = h_list if isinstance(h_list, list) else [h_list] + for h in items: + if isinstance(h, dict): + if h.get("id"): + self.payment_handlers.add(str(h["id"])) + if h.get("name"): + self.payment_handlers.add(str(h["name"])) + + # Extract services + self.services: dict[str, list[dict[str, Any]]] = {} + svcs = ucp_section.get("services", {}) + if isinstance(svcs, dict): + for svc_name, svc_items in svcs.items(): + self.services[svc_name] = ( + svc_items if isinstance(svc_items, list) else [svc_items] + ) + + def supports_capability( + self, capability_name: str, min_version: str | None = None + ) -> bool: + """Check if the server advertises a capability. + + Optionally checks version requirement. + """ + if capability_name not in self.capabilities: + return False + return not ( + min_version and self.capabilities[capability_name].version < min_version + ) + + def supports_payment_handler(self, handler_id: str) -> bool: + """Check if the server supports a payment handler by ID or name.""" + return handler_id in self.payment_handlers + + +def fetch_server_profile(base_url: str, timeout: float = 10.0) -> UcpProfile: + """Fetch and parse the discovery profile from the target server.""" + endpoint = f"{base_url.rstrip('/')}/.well-known/ucp" + with httpx.Client(timeout=timeout) as client: + response = client.get(endpoint) + response.raise_for_status() + return UcpProfile(response.json()) diff --git a/framework/mock_webhook_server.py b/framework/mock_webhook_server.py new file mode 100644 index 0000000..035148f --- /dev/null +++ b/framework/mock_webhook_server.py @@ -0,0 +1,93 @@ +# 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. + +"""Background mock webhook receiver for capturing incoming server callbacks.""" + +import contextlib +import threading +import time +from typing import Any +from fastapi import FastAPI, Request +import httpx +import uvicorn + + +class MockWebhookServer: + """Captures incoming webhook events for assertion during tests.""" + + def __init__(self, port: int): + """Initialize MockWebhookServer listening on the specified port. + + Args: + port: The port to listen on. + + """ + self.port = port + self.app = FastAPI() + self.events: list[dict[str, Any]] = [] + self._setup_routes() + self._server: uvicorn.Server | None = None + self._thread: threading.Thread | None = None + + def _setup_routes(self) -> None: + @self.app.post("/{full_path:path}") + async def capture_all(request: Request, full_path: str) -> dict[str, str]: + headers = dict(request.headers) + body = await request.body() + json_payload = None + with contextlib.suppress(Exception): + json_payload = await request.json() + self.events.append( + { + "path": full_path, + "headers": headers, + "raw_body": body, + "json": json_payload, + } + ) + return {"status": "ok"} + + @self.app.get("/healthz") + async def health() -> dict[str, str]: + return {"status": "ok"} + + def start(self) -> None: + """Start the mock webhook server in a background thread.""" + config = uvicorn.Config( + self.app, host="0.0.0.0", port=self.port, log_level="error" + ) + self._server = uvicorn.Server(config) + self._thread = threading.Thread(target=self._server.run, daemon=True) + self._thread.start() + + for _ in range(50): + try: + with httpx.Client() as client: + resp = client.get(f"http://localhost:{self.port}/healthz") + if resp.status_code == 200: + return + except httpx.ConnectError: + time.sleep(0.05) + raise RuntimeError(f"MockWebhookServer failed to start on port {self.port}") + + def stop(self) -> None: + """Stop the background mock webhook server.""" + if self._server is not None: + self._server.should_exit = True + if self._thread is not None: + self._thread.join(timeout=3) + + def clear_events(self) -> None: + """Clear all recorded webhook events.""" + self.events.clear() diff --git a/framework/platform.py b/framework/platform.py new file mode 100644 index 0000000..a289bbb --- /dev/null +++ b/framework/platform.py @@ -0,0 +1,88 @@ +# 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. + +"""Platform requirement profile evaluator.""" + +from dataclasses import dataclass +from pathlib import Path +from framework.discovery import UcpProfile +import yaml + + +@dataclass +class PlatformRequirement: + """Requirement specification for a single capability.""" + + name: str + min_version: str | None = None + required: bool = True + + +@dataclass +class PlatformProfile: + """Certification profile containing mandated capabilities and handlers.""" + + platform_name: str + required_capabilities: list[PlatformRequirement] + required_payment_handlers: list[str] + + @classmethod + def from_yaml(cls, path: str | Path) -> "PlatformProfile": + """Load a platform requirement profile from a YAML file.""" + with Path(path).open(encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + + caps = [ + PlatformRequirement( + name=c["name"], + min_version=c.get("min_version"), + required=c.get("required", True), + ) + for c in data.get("capabilities", []) + if isinstance(c, dict) and "name" in c + ] + handlers = [str(h) for h in data.get("payment_handlers", [])] + return cls( + platform_name=data.get("platform", "unknown"), + required_capabilities=caps, + required_payment_handlers=handlers, + ) + + def validate(self, server_profile: UcpProfile) -> list[str]: + """Validate server profile against platform requirements. + + Args: + server_profile: The discovered server profile from /.well-known/ucp. + + Returns: + A list of error strings describing any unmet requirements. + + """ + errors = [] + for req in self.required_capabilities: + if req.required and not server_profile.supports_capability( + req.name, req.min_version + ): + errors.append( + f"Platform '{self.platform_name}' requires capability" + f" '{req.name}' (min_version: {req.min_version or 'any'}), but it" + " is not supported." + ) + for handler in self.required_payment_handlers: + if not server_profile.supports_payment_handler(handler): + errors.append( + f"Platform '{self.platform_name}' requires payment handler" + f" '{handler}', but it is missing from /.well-known/ucp." + ) + return errors diff --git a/integration_test_utils.py b/integration_test_utils.py index 21d38af..5a640a2 100644 --- a/integration_test_utils.py +++ b/integration_test_utils.py @@ -12,881 +12,79 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Shared utilities for UCP SDK integration tests.""" +"""Backward compatibility facade for legacy integration test utilities. -import csv -import json -import logging -from pathlib import Path -import threading -import time -from typing import Any -import uuid +Deprecated: + Use `framework.base_test.BaseIntegrationTest` for core/generic protocol tests + and `shopping.base.ShoppingIntegrationTestBase` for retail shopping tests. +""" -from absl import flags -from absl.testing import absltest -from fastapi import FastAPI -from fastapi import Request -from fastapi.responses import JSONResponse -import httpx -from ucp_sdk.models.schemas.shopping import checkout_create_request -from ucp_sdk.models.schemas.shopping import checkout as f_models -from ucp_sdk.models.schemas.shopping import payment_create_request -from ucp_sdk.models.schemas.shopping import payment_update_request -from ucp_sdk.models.schemas.shopping.checkout_update_request import ( - CheckoutUpdateRequest, -) -from ucp_sdk.models.schemas.shopping.types import ( - fulfillment_group_create_request, -) -from ucp_sdk.models.schemas.shopping.types import ( - fulfillment_method_create_request, +import uuid +import warnings +from framework.agent_profile_server import AgentProfileServer +from framework.base_test import BaseIntegrationTest, FLAGS +from framework.mock_webhook_server import MockWebhookServer +from shopping.base import ( + DEFAULT_SHOPPING_FIXTURES, + DynamicFixtureContext, + get_valid_payment_payload, + shopping_test_data, + ShoppingIntegrationTestBase, + ShoppingTestData, + UnifiedUpdate, ) -from ucp_sdk.models.schemas.shopping.types import item_create_request -from ucp_sdk.models.schemas.shopping.types import item_update_request -from ucp_sdk.models.schemas.shopping.types import line_item_create_request -from ucp_sdk.models.schemas.shopping.types import line_item_update_request -from ucp_sdk.models.schemas import payment_handler -from ucp_sdk.models.schemas.shopping.types import shipping_destination -import uvicorn - - -class UnifiedUpdate(CheckoutUpdateRequest): - """Client-side unified update model to support extensions.""" - - -FLAGS = flags.FLAGS -try: - flags.DEFINE_string("server_url", None, "Base URL of the server") - flags.DEFINE_string( - "simulation_secret", - str(uuid.uuid4()), - "Secret for simulation endpoints", - ) - flags.DEFINE_integer( - "mock_webhook_port", 8284, "Port for the mock webhook server" - ) - flags.DEFINE_integer( - "mock_agent_port", 8285, "Port for the mock agent profile server" - ) - flags.DEFINE_bool("verbose_http", False, "Whether to log HTTP requests.") - flags.DEFINE_string( - "conformance_input", - "test_data/flower_shop/conformance_input.json", - "Path to conformance input configuration JSON.", - ) - flags.DEFINE_string( - "test_data_dir", - "test_data/flower_shop", - "Directory containing test CSV data.", - ) -except flags.DuplicateFlagError: - pass - -class TestData: - """Holder for loaded test data.""" - - def __init__(self) -> None: - """Initialize TestData.""" - self.payment_instruments: list[dict[str, Any]] = [] - self.addresses: list[dict[str, Any]] = [] - - def load(self, data_dir: str) -> None: - """Load data from CSV files in the given directory.""" - pi_path = Path(data_dir) / "payment_instruments.csv" - if pi_path.exists(): - with pi_path.open() as f: - self.payment_instruments = list(csv.DictReader(f)) - - addr_path = Path(data_dir) / "addresses.csv" - if addr_path.exists(): - with addr_path.open() as f: - self.addresses = list(csv.DictReader(f)) +warnings.warn( + "integration_test_utils is deprecated and will be removed in a future" + " release. Use framework.base_test and shopping.base instead.", + DeprecationWarning, + stacklevel=2, +) +# Re-export aliases for backward compatibility +IntegrationTestBase = ShoppingIntegrationTestBase +TestData = ShoppingTestData +test_data = shopping_test_data +fixture_ctx: DynamicFixtureContext | None = None -# Global instance -test_data = TestData() +DEFAULT_CONFORMANCE_INPUT = ( + f"{DEFAULT_SHOPPING_FIXTURES}/conformance_input.json" +) +DEFAULT_FIXTURE_CONFIG = f"{DEFAULT_SHOPPING_FIXTURES}/test_fixtures.json" def get_headers( idempotency_key: str | None = None, request_id: str | None = None ) -> dict[str, str]: - """Generate headers for UCP requests. - - Args: - idempotency_key: Optional specific idempotency key. - request_id: Optional specific request ID. - - Returns: - A dictionary of HTTP headers including UCP-Agent, signature, and keys. - - """ - profile_url = ( - f"http://localhost:{FLAGS.mock_agent_port}{AgentProfileServer.PROFILE_PATH}" - ) - return { + """Generate headers for UCP requests.""" + port = FLAGS.mock_agent_port if FLAGS.is_parsed() else 8285 + profile_url = f"http://localhost:{port}{AgentProfileServer.PROFILE_PATH}" + headers = { "UCP-Agent": f'profile="{profile_url}"', "request-signature": "test", - "idempotency-key": idempotency_key or str(uuid.uuid4()), "request-id": request_id or str(uuid.uuid4()), + "Content-Type": "application/json", } - - -def get_valid_payment_payload( - instrument_id: str = "instr_1", address_id: str = "addr_1" -) -> dict[str, Any]: - """Return a valid payment payload using loaded test data.""" - # Find instrument - instr_data = next( - (pi for pi in test_data.payment_instruments if pi["id"] == instrument_id), - None, - ) - if not instr_data: - # Fallback to hardcoded if not loaded (e.g. unit tests without files) - instr_data = { - "id": "instr_1", - "type": "card", - "brand": "Visa", - "last_digits": "1234", - "token": "success_token", - "handler_id": "mock_payment_handler", - } - - # Find address - addr_data = next( - (a for a in test_data.addresses if a["id"] == address_id), None - ) - if not addr_data: - addr_data = { - "street_address": "123 Main St", - "city": "Anytown", - "state": "CA", - "postal_code": "12345", - "country": "US", - } - - # Construct Billing Address - billing_address = { - "street_address": addr_data.get("street_address"), - "address_locality": addr_data.get("city"), - "address_region": addr_data.get("state"), - "address_country": addr_data.get("country"), - "postal_code": addr_data.get("postal_code"), - } - - payment_instrument = { - "id": instr_data["id"], - "handler_id": instr_data["handler_id"], - "type": instr_data["type"], - "display": { - "brand": instr_data["brand"], - "last_digits": instr_data["last_digits"], - }, - "credential": {"type": "token", "token": instr_data["token"]}, - "billing_address": billing_address, - } - - return { - "payment": {"instruments": [payment_instrument]}, - "risk_signals": {}, - } - - -class AgentProfileServer: - """A background mock agent server that serves the agent profile.""" - - PROFILE_PATH = "/profiles/shopping-agent.json" - - def __init__(self, *, port: int, webhook_port: int): - """Initialize the AgentProfileServer. - - Args: - port: The port to listen on. - webhook_port: The port where the webhook server is listening. - - """ - self.port = port - self.webhook_port = webhook_port - self.app = FastAPI() - - # Resolve and pre-read the profile template to avoid repeated file I/O - current_dir = Path(__file__).resolve().parent - self.profile_path = current_dir / "shopping-agent-test.json" - with self.profile_path.open() as f: - self._profile_template = f.read() - - self._setup_routes() - self._server: uvicorn.Server | None - self._thread: threading.Thread | None - - def _setup_routes(self) -> None: - """Set up the routes for the mock agent server.""" - - @self.app.get(self.PROFILE_PATH, response_model=None) - async def get_profile() -> JSONResponse: - """Return the agent profile with the correct webhook port injected.""" - # Dynamically inject the correct webhook port into the cached template - content = self._profile_template.replace( - "{webhook_port}", str(self.webhook_port) - ) - content_dict = json.loads(content) - return JSONResponse(content=content_dict) - - @self.app.get("/healthz") - async def health_check() -> dict[str, str]: - """Return a simple health check response.""" - return {"status": "ok"} - - def start(self) -> None: - """Start the mock server in a background thread.""" - config = uvicorn.Config( - self.app, host="0.0.0.0", port=self.port, log_level="error" - ) - self._server = uvicorn.Server(config) - self._thread = threading.Thread(target=self._server.run, daemon=True) - self._thread.start() - # Wait for server to start - for _ in range(50): - try: - with httpx.Client() as client: - if ( - client.get(f"http://localhost:{self.port}/healthz").status_code - == 200 - ): - break - except httpx.ConnectError: - time.sleep(0.1) - else: - raise RuntimeError(f"Server failed to start on port {self.port}") - - def stop(self) -> None: - """Stop the mock server.""" - if self._server is not None: - self._server.should_exit = True - if self._thread is not None: - self._thread.join(timeout=5) - - -class MockWebhookServer: - """A background mock webhook server that records incoming events.""" - - def __init__(self, port: int): - """Initialize the MockWebhookServer. - - Args: - port: The port to listen on. - - """ - self.port = port - self.app = FastAPI() - self.events: list[dict[str, Any]] = [] - self._setup_routes() - self._server: uvicorn.Server | None - self._thread: threading.Thread | None - - def _setup_routes(self) -> None: - """Set up the routes for the mock server.""" - - @self.app.post("/webhooks/partners/{partner_id}/events/order") - async def order_event(partner_id: str, request: Request) -> dict[str, str]: - """Record an incoming order event.""" - payload = await request.json() - self.events.append({"partner_id": partner_id, "payload": payload}) - return {"status": "ok"} - - @self.app.get("/healthz") - async def health_check() -> dict[str, str]: - """Return a simple health check response.""" - return {"status": "ok"} - - def start(self) -> None: - """Start the mock server in a background thread.""" - config = uvicorn.Config( - self.app, host="0.0.0.0", port=self.port, log_level="error" - ) - self._server = uvicorn.Server(config) - self._thread = threading.Thread(target=self._server.run, daemon=True) - self._thread.start() - # Wait for server to start - for _ in range(50): - try: - with httpx.Client() as client: - if ( - client.get(f"http://localhost:{self.port}/healthz").status_code - == 200 - ): - break - except httpx.ConnectError: - time.sleep(0.1) - else: - raise RuntimeError(f"Server failed to start on port {self.port}") - - def stop(self) -> None: - """Stop the mock server.""" - if self._server is not None: - self._server.should_exit = True - if self._thread is not None: - self._thread.join(timeout=5) - - def clear_events(self) -> None: - """Clear all recorded events.""" - self.events = [] - - -class IntegrationTestBase(absltest.TestCase): - """Base class for UCP integration tests providing setup and helper methods.""" - - def setUp(self) -> None: - """Set up the test case, including clients and mock servers.""" - super().setUp() - self.base_url = FLAGS.server_url - self.client = httpx.Client(base_url=self.base_url) - - # Configure httpx logging based on flag - httpx_logger = logging.getLogger("httpx") - if FLAGS.verbose_http: - httpx_logger.setLevel(logging.INFO) - else: - httpx_logger.setLevel(logging.WARNING) - - # Load conformance input configuration - try: - with Path(FLAGS.conformance_input).open() as f: - self.conformance_config = json.load(f) - except FileNotFoundError: - logging.warning( - "Conformance input file not found at %s. Using defaults.", - FLAGS.conformance_input, - ) - self.conformance_config = {} - - # Load CSV Test Data - try: - # Resolve relative to this file if not absolute - data_dir = FLAGS.test_data_dir - if not Path(data_dir).is_absolute(): - # Assumption: run from where this file is reachable via relative path - # Actually, FLAGS.test_data_dir is passed by run_conformance.sh - # Let's try to resolve it. - pass - test_data.load(data_dir) - except Exception as e: # pylint: disable=broad-exception-caught - logging.warning("Failed to load test CSV data: %s", e) - - # Start the agent profile server - self.agent_server = AgentProfileServer( - port=FLAGS.mock_agent_port, webhook_port=FLAGS.mock_webhook_port - ) - self.agent_server.start() - self._shopping_service_endpoint: str | None = None - - @property - def shopping_service_endpoint(self) -> str: - """Cached property for the shopping service endpoint.""" - if self._shopping_service_endpoint is None: - discovery_resp = self.client.get("/.well-known/ucp") - self.assert_response_status(discovery_resp, 200) - - profile_data = discovery_resp.json() - # Support both wrapped and unwrapped (UCP wrapper) - ucp_data = profile_data.get("ucp", profile_data) - # UCP 01-23 validation changed dicts to lists - shopping_services = ucp_data.get("services", {}).get( - "dev.ucp.shopping", [] - ) - if not shopping_services: - raise RuntimeError("Shopping service not found in discovery profile") - - shopping_service = ( - shopping_services[0] - if isinstance(shopping_services, list) - else shopping_services - ) - - endpoint = ( - shopping_service.get("endpoint") - if shopping_service and shopping_service.get("transport") == "rest" - else None - ) - if not endpoint: - raise RuntimeError( - "Shopping service endpoint not found in discovery profile" - ) - self._shopping_service_endpoint = str(endpoint) - return self._shopping_service_endpoint - - def get_shopping_url(self, path: str) -> str: - """Construct a full URL for the shopping service. - - Args: - path: The path to append to the service endpoint - (e.g., '/checkout-sessions'). - - Returns: - The full URL. - - """ - base = self.shopping_service_endpoint.rstrip("/") - path = path.lstrip("/") - return f"{base}/{path}" - - def get_order_url(self, order_id: str) -> str: - """Construct a full URL for an order resource.""" - return self.get_shopping_url(f"/orders/{order_id}") - - def tearDown(self) -> None: - """Tear down the test case, stopping servers and clients.""" - self.client.close() - if hasattr(self, "agent_server"): - self.agent_server.stop() - super().tearDown() - - def create_checkout_payload( - self, - quantity=1, - item_id: str | None = None, - currency: str | None = None, - handlers=None, - buyer: dict[str, Any] | None = None, - include_fulfillment: bool = True, - ) -> checkout_create_request.CheckoutCreateRequest: - """Create a valid checkout creation payload. - - Args: - quantity: Number of items to purchase. Defaults to 1. - item_id: ID of the item. Defaults to config or "item_1". - currency: Currency code. Defaults to config or "USD". - handlers: Optional list of payment handlers. If None, defaults to Google - Pay. - buyer: Optional buyer information dictionary. - include_fulfillment: Whether to include default fulfillment details. - - Returns: - A CheckoutCreateRequest object populated with the specified data. - - """ - # Load defaults from config if not provided - default_item = ( - self.conformance_config.get("items", [{}])[0] - if self.conformance_config - else {} - ) - - if item_id is None: - item_id = default_item.get("id", "item_1") - if currency is None: - currency = self.conformance_config.get("currency", "USD") - - if handlers is None: - handlers = [ - payment_handler.Base( - id="google_pay", - name="google.pay", - version="2026-04-08", - spec="https://example.com/spec", - config_schema="https://example.com/schema", - instrument_schemas=["https://example.com/instrument_schema"], - config={}, - ) - ] - - item = item_create_request.ItemCreateRequest(id=item_id) - line_item = line_item_create_request.LineItemCreateRequest( - quantity=quantity, item=item - ) - - # PaymentCreateRequest allows extra fields, so passing handlers is valid - payment = payment_create_request.PaymentCreateRequest( - instruments=[], - handlers=[h.model_dump(mode="json", exclude_none=True) for h in handlers], - ) - - fulfillment = None - if include_fulfillment: - # Hierarchical Fulfillment Construction - destination = shipping_destination.ShippingDestination( - id="dest_1", address_country="US" - ) - group = fulfillment_group_create_request.FulfillmentGroupCreateRequest( - id="group_1", - line_item_ids=["line_item_123"], - selected_option_id="std-ship", - ) - method = fulfillment_method_create_request.FulfillmentMethodCreateRequest( - id="method_1", - type="shipping", - destinations=[destination], - line_item_ids=["line_item_123"], - selected_destination_id="dest_1", - groups=[group], - ) - fulfillment = { - "methods": [ - method.model_dump(mode="json", exclude_none=True, by_alias=True) - ] - } - - # Set response fields on model objects for server validation workaround - item.price = 1000 - line_item.id = "line_item_123" - line_item.totals = [] - - checkout_req = checkout_create_request.CheckoutCreateRequest( - id=str(uuid.uuid4()), - currency=currency, - line_items=[line_item], - payment=payment, - buyer=buyer, - fulfillment=fulfillment, - ) - checkout_req.status = "incomplete" - checkout_req.ucp = {"version": "2026-04-08"} - checkout_req.totals = [] - checkout_req.links = [] - - return checkout_req - - def get_headers( - self, idempotency_key: str | None = None, request_id: str | None = None - ) -> dict[str, str]: - """Generate headers for UCP requests (instance method). - - Args: - idempotency_key: Optional specific idempotency key. - request_id: Optional specific request ID. - - Returns: - A dictionary of HTTP headers including UCP-Agent, signature, and keys. - - """ - return get_headers(idempotency_key, request_id) - - def assert_response_status( - self, response: httpx.Response, expected_code: int | list[int] - ) -> None: - """Assert that the response status code matches the expected code(s). - - Args: - response: The httpx response object. - expected_code: An integer or list of integers representing valid status - codes. - - Raises: - AssertionError: If the response status code is not in expected_code. - - """ - if isinstance(expected_code, int): - expected_codes = [expected_code] - else: - expected_codes = expected_code - - self.assertIn( - response.status_code, - expected_codes, - msg=( - f"Expected status {expected_code}, got {response.status_code}." - f" Resp: {response.text}" - ), - ) - - def create_checkout_session( - self, - quantity: int = 1, - item_id: str | None = None, - currency: str | None = None, - handlers: list[Any] | None = None, - buyer: dict[str, Any] | None = None, - select_fulfillment: bool = True, - headers: dict[str, str] | None = None, - ) -> Any: - """Create a checkout session and return the response JSON. - - Args: - quantity: Number of items to purchase. Defaults to 1. - item_id: ID of the item. Defaults to config or "item_1". - currency: Currency code. Defaults to config or "USD". - handlers: Optional list of payment handlers. If None, defaults to Google - Pay. - buyer: Optional buyer information dictionary. - select_fulfillment: Whether to automatically select a fulfillment - option. Defaults to True. - headers: Optional headers to include in the request. - - Returns: - The JSON response dictionary from the create request. - - """ - create_payload = self.create_checkout_payload( - quantity=quantity, - item_id=item_id, - currency=currency, - handlers=handlers, - buyer=buyer, - include_fulfillment=select_fulfillment, - ) - - request_headers = self.get_headers() - if headers: - request_headers.update(headers) - - response = self.client.post( - self.get_shopping_url("/checkout-sessions"), - json=create_payload.model_dump( - mode="json", by_alias=True, exclude_none=True - ), - headers=request_headers, - ) - self.assert_response_status(response, [200, 201]) - checkout_data = response.json() - - if select_fulfillment: - checkout_data = self.ensure_fulfillment_ready(checkout_data["id"]) - - return checkout_data - - def ensure_fulfillment_ready(self, checkout_id: str) -> Any: - """Ensure a fulfillment option is selected for the checkout. - - Args: - checkout_id: The ID of the checkout to check and update. - - Returns: - The updated checkout data dictionary. - - """ - response = self.client.get( - self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), - headers=self.get_headers(), - ) - checkout_data = response.json() - - # Helper to check if ready - def is_ready(data): - if not data.get("fulfillment") or not data["fulfillment"].get("methods"): - return False - method = data["fulfillment"]["methods"][0] - if not method.get("selected_destination_id"): - return False - return method.get("groups") and method["groups"][0].get( - "selected_option_id" - ) - - if is_ready(checkout_data): - return checkout_data - - checkout_obj = f_models.Checkout(**checkout_data) - - # 1. Trigger fulfillment with a default address if none exists - has_destinations = ( - checkout_data.get("fulfillment") - and checkout_data["fulfillment"].get("methods") - and checkout_data["fulfillment"]["methods"][0].get("destinations") - ) - - if not has_destinations: - # Inject a default US address - address = { - "id": "dest_default", - "street_address": "123 Default St", - "address_locality": "City", - "address_region": "State", - "postal_code": "12345", - "address_country": "US", - } - # Preserve method ID if exists - method_id = None - if checkout_data.get("fulfillment") and checkout_data["fulfillment"].get( - "methods" - ): - method_id = checkout_data["fulfillment"]["methods"][0].get("id") - - method_payload = { - "type": "shipping", - "destinations": [address], - "selected_destination_id": "dest_default", - } - if method_id: - method_payload["id"] = method_id - - checkout_data = self.update_checkout_session( - checkout_obj, - fulfillment={"methods": [method_payload]}, - ) - checkout_obj = f_models.Checkout(**checkout_data) - - # 2. Select Destination (if not already selected) - method = checkout_data["fulfillment"]["methods"][0] - if not method.get("selected_destination_id") and method.get("destinations"): - dest_id = method["destinations"][0]["id"] - - # Construct update preserving destinations - method_payload = method.copy() - # method is a dict from json response - # We need to ensure we send back valid update data - # Response might have fields not valid for update? - # Usually safe to send back what we got + changes for this simple server - method_payload["selected_destination_id"] = dest_id - # Ensure we keep destinations - - checkout_data = self.update_checkout_session( - checkout_obj, - fulfillment={"methods": [method_payload]}, - ) - checkout_obj = f_models.Checkout(**checkout_data) - - # 3. Select Option - method = checkout_data["fulfillment"]["methods"][0] - has_selection = False - if method.get("groups"): - for g in method["groups"]: - if g.get("selected_option_id"): - has_selection = True - break - - if not has_selection and ( - method.get("groups") and method["groups"][0].get("options") - ): - option_id = method["groups"][0]["options"][0]["id"] - - # Update group - method_payload = method.copy() - # Ensure groups is a list of dicts - method_payload["groups"][0]["selected_option_id"] = option_id - - checkout_data = self.update_checkout_session( - checkout_obj, - fulfillment={"methods": [method_payload]}, - ) - - return checkout_data - - def complete_checkout_session( - self, checkout_id: str, payment_payload: dict[str, Any] | None = None - ) -> Any: - """Complete a checkout session. - - Args: - checkout_id: The ID of the checkout to complete. - payment_payload: Optional custom payment payload. If None, uses a valid - default. - - Returns: - The JSON response dictionary from the complete request. - - """ - # Ensure fulfillment is set (required by server) - self.ensure_fulfillment_ready(checkout_id) - - if payment_payload is None: - payment_payload = get_valid_payment_payload() - - response = self.client.post( - self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), - json=payment_payload, - headers=self.get_headers(), - ) - self.assert_response_status(response, 200) - return response.json() - - def create_completed_order(self) -> str: - """Orchestrate checkout creation and completion. - - This helper combines create_checkout_session and complete_checkout_session - to quickly reach a "completed order" state for testing post-order - operations. - - Returns: - The 'order_id' from the completion response. - - """ - checkout_data = self.create_checkout_session() - checkout_id = checkout_data["id"] - complete_data = self.complete_checkout_session(checkout_id) - return complete_data["order"]["id"] - - def update_checkout_session( - self, - checkout_obj: Any, - currency: str | None = None, - line_items: list[Any] | None = None, - payment: Any | None = None, - buyer: Any | None = None, - fulfillment: Any | None = None, - discounts: Any | None = None, - platform: Any | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Update a checkout session. - - Constructs a partial update request based on the existing checkout object - and any provided override fields. - - Args: - checkout_obj: The current checkout object (from response model). - currency: Optional currency code. - line_items: Optional list of line items. - payment: Optional payment object. - buyer: Optional buyer object. - fulfillment: Optional fulfillment object (nested structure). - discounts: Optional discounts. - platform: Optional platform config. - headers: Optional headers to include in the request. - - Returns: - The JSON response dictionary from the update request. - - """ - # Default to existing values if not provided - currency = currency if currency is not None else checkout_obj.currency - - # Construct Line Items - if line_items is None: - line_items = [] - for li in checkout_obj.line_items: - item_update = item_update_request.ItemUpdateRequest( - id=li.item.id, - ) - line_items.append( - line_item_update_request.LineItemUpdateRequest( - id=li.id, - item=item_update, - quantity=li.quantity, - parent_id=li.parent_id, - ) - ) - - # Construct Payment - if payment is None: - payment = ( - payment_update_request.PaymentUpdateRequest( - instruments=getattr(checkout_obj.payment, "instruments", []), - ) - if checkout_obj.payment - else None - ) - - update_payload = UnifiedUpdate( - id=checkout_obj.id, - currency=currency, - line_items=line_items, - payment=payment, - buyer=buyer, - fulfillment=fulfillment, - discounts=discounts, - platform=platform, - ) - - request_headers = self.get_headers() - if headers: - request_headers.update(headers) - - response = self.client.put( - self.get_shopping_url(f"/checkout-sessions/{checkout_obj.id}"), - json=update_payload.model_dump( - mode="json", by_alias=True, exclude_none=True - ), - headers=request_headers, - ) - self.assert_response_status(response, 200) - return response.json() + if idempotency_key: + headers["idempotency-key"] = idempotency_key + return headers + + +__all__ = [ + "AgentProfileServer", + "BaseIntegrationTest", + "DEFAULT_CONFORMANCE_INPUT", + "DEFAULT_FIXTURE_CONFIG", + "DynamicFixtureContext", + "FLAGS", + "IntegrationTestBase", + "MockWebhookServer", + "ShoppingIntegrationTestBase", + "ShoppingTestData", + "TestData", + "UnifiedUpdate", + "fixture_ctx", + "get_headers", + "get_valid_payment_payload", + "test_data", +] diff --git a/platforms/google.yaml b/platforms/google.yaml new file mode 100644 index 0000000..5df2a64 --- /dev/null +++ b/platforms/google.yaml @@ -0,0 +1,12 @@ +platform: "google" +capabilities: + - name: "dev.ucp.shopping.checkout" + required: true + - name: "dev.ucp.shopping.fulfillment" + required: true + - name: "dev.ucp.shopping.order" + required: true + - name: "dev.ucp.shopping.discount" + required: false +payment_handlers: + - "google_pay" diff --git a/protocol_test.py b/protocol_test.py deleted file mode 100644 index d246692..0000000 --- a/protocol_test.py +++ /dev/null @@ -1,299 +0,0 @@ -# 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. - -"""Protocol tests for the UCP SDK Server.""" - -from absl.testing import absltest -import integration_test_utils -import httpx -from pydantic import TypeAdapter, ValidationError -from ucp_sdk.models.schemas.ucp import BusinessSchema -from ucp_sdk.models.schemas.shopping.types.reverse_domain_name import ( - ReverseDomainName, -) -from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) - -# Rebuild models to resolve forward references -checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) - - -class ProtocolTest(integration_test_utils.IntegrationTestBase): - """Tests for UCP protocol compliance. - - Validated Paths: - - GET /.well-known/ucp - - POST /checkout-sessions - """ - - def _extract_document_urls( - self, profile: BusinessSchema - ) -> list[tuple[str, str]]: - """Extract all spec and schema URLs from the discovery profile. - - Returns: - A list of (JSON path, URL) tuples. - - """ - if isinstance(profile, dict): - profile = profile.get("ucp", profile) - urls = set() - - # 1. Services - for service_name, services_list in profile.get("services", {}).items(): - for svc_idx, service in enumerate( - services_list if isinstance(services_list, list) else [services_list] - ): - base_path = f"services['{service_name}'][{svc_idx}]" - if service.get("spec"): - urls.add((f"{base_path}.spec", str(service.get("spec")))) - if service.get("transport") == "rest" and service.get("schema"): - urls.add((f"{base_path}.schema", str(service.get("schema")))) - if service.get("transport") == "mcp" and service.get("schema"): - urls.add((f"{base_path}.schema", str(service.get("schema")))) - if service.get("transport") == "embedded" and service.get("schema"): - urls.add((f"{base_path}.schema", str(service.get("schema")))) - - # 2. Capabilities - for _cap_key, caps in profile.get("capabilities", {}).items(): - for i, cap in enumerate(caps if isinstance(caps, list) else [caps]): - cap_name = cap.get("name") or f"index_{i}" - base_path = f"ucp.capabilities['{cap_name}']" - if cap.get("spec"): - urls.add((f"{base_path}.spec", str(cap.get("spec")))) - if cap.get("schema"): - urls.add((f"{base_path}.schema", str(cap.get("schema")))) - - # 3. Payment Handlers - for domain, handlers in profile.get("payment_handlers", {}).items(): - for i, handler in enumerate( - handlers if isinstance(handlers, list) else [handlers] - ): - handler_id = handler.get("id") or f"{domain}_index_{i}" - base_path = f"payment_handlers['{handler_id}']" - if handler.get("spec"): - urls.add((f"{base_path}.spec", str(handler.get("spec")))) - if handler.get("config_schema"): - urls.add( - (f"{base_path}.config_schema", str(handler.get("config_schema"))) - ) - if handler.get("instrument_schemas"): - for j, s in enumerate(handler.get("instrument_schemas", [])): - urls.add((f"{base_path}.instrument_schemas[{j}]", str(s))) - - return sorted(urls, key=lambda x: x[0]) - - import unittest - - @unittest.skip("Schemas not yet published on remote ucp.dev domain") - def test_discovery_urls(self): - """Verify all spec and schema URLs in discovery profile are valid. - - Fetches each URL and verifies it returns 200 OK and valid HTML/JSON. - """ - response = self.client.get("/.well-known/ucp") - self.assert_response_status(response, 200) - profile = response.json() - - url_entries = self._extract_document_urls(profile) - failures = [] - - with httpx.Client(follow_redirects=True, timeout=10.0) as external_client: - # Sort by path for consistent output - for path, url in sorted(url_entries, key=lambda x: x[0]): - # Use internal client for local URLs, external client otherwise - client = ( - self.client if url.startswith(self.base_url) else external_client - ) - - try: - # Handle relative URLs if any (AnyUrl should be absolute though) - res = client.get(url) - if res.status_code != 200: - failures.append(f"[{path}] {url} returned status {res.status_code}") - continue - - content_type = res.headers.get("content-type", "").lower() - if "json" in content_type: - try: - res.json() - except Exception as e: - failures.append(f"[{path}] {url} (JSON) failed to parse: {e}") - elif "html" in content_type: - is_valid_html = ( - "=0.109.0", "uvicorn[standard]>=0.38.0", "ruff>=0.14.11", + "pyyaml>=6.0", ] +[project.scripts] +ucp-conformance = "runner:main" + [dependency-groups] dev = [] @@ -25,7 +29,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["."] +packages = ["framework", "core", "common", "shopping", "platforms"] [tool.uv.sources] # The relative path is stored here diff --git a/runner.py b/runner.py new file mode 100644 index 0000000..3a5770c --- /dev/null +++ b/runner.py @@ -0,0 +1,300 @@ +# 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. + +"""Dynamic CLI test runner and capability orchestrator for UCP.""" + +import argparse +from collections.abc import Iterator +import importlib +import logging +import os +from pathlib import Path +import sys +import unittest +from absl import flags +from framework.base_test import BaseIntegrationTest +from framework.discovery import UcpProfile, fetch_server_profile +from framework.platform import PlatformProfile + +SUITE_REGISTRY: dict[str, list[str]] = { + "core": [ + "core.protocol_test", + "core.binding_test", + "core.security_test", + "core.idempotency_test", + ], + "common": [ + "common.webhooks.webhook_test", + "common.payments.card_credential_test", + "common.payments.ap2_test", + ], + "shopping": [ + "shopping.checkout.lifecycle_test", + "shopping.checkout.business_logic_test", + "shopping.discount.discount_test", + "shopping.fulfillment.fulfillment_test", + "shopping.order.order_test", + "shopping.validation.validation_test", + "shopping.validation.invalid_input_test", + ], +} + + +def _resolve_suites(suite_arg: str) -> list[str]: + """Resolve comma-separated suite names to target test class paths.""" + selected = set() + tokens = [s.strip() for s in suite_arg.split(",") if s.strip()] + for token in tokens: + if token == "all": + for tests in SUITE_REGISTRY.values(): + selected.update(tests) + elif token in SUITE_REGISTRY: + selected.update(SUITE_REGISTRY[token]) + else: + # Assume direct class, module, or file path + normalized = token.replace("/", ".").removesuffix(".py") + selected.add(normalized) + return sorted(selected) + + +def _iter_tests(suite: unittest.TestSuite) -> Iterator[unittest.TestCase]: + """Iterate through all test cases in a nested TestSuite.""" + for item in suite: + if isinstance(item, unittest.TestSuite): + yield from _iter_tests(item) + elif isinstance(item, unittest.TestCase): + yield item + + +def _load_tests( + specifiers: list[str], filter_pattern: str | None = None +) -> unittest.TestSuite: + """Load unittest test cases from module or class specifiers.""" + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + for spec in specifiers: + # First try loading directly as a module + try: + mod = importlib.import_module(spec) + suite.addTests(loader.loadTestsFromModule(mod)) + continue + except ModuleNotFoundError: + pass + + # If not a direct module, try parent module and class/method attribute + if "." in spec: + parent, attr = spec.rsplit(".", 1) + try: + mod = importlib.import_module(parent) + cls = getattr(mod, attr, None) + if cls and isinstance(cls, type) and issubclass(cls, unittest.TestCase): + suite.addTests(loader.loadTestsFromTestCase(cls)) + continue + except (ImportError, AttributeError) as exc: + logging.error("Failed to import test specifier %s: %s", spec, exc) + else: + logging.error("Unknown test specifier: %s", spec) + + if filter_pattern: + filtered = unittest.TestSuite() + pattern = filter_pattern.lower() + for test in _iter_tests(suite): + if pattern in test.id().lower(): + filtered.addTest(test) + return filtered + + return suite + + +def _sync_absl_flags(args: argparse.Namespace) -> None: + """Synchronize parsed CLI arguments with absl flags and environment.""" + flag_args = [ + sys.argv[0], + f"--server_url={args.server_url}", + f"--simulation_secret={args.simulation_secret or ''}", + f"--mock_webhook_port={args.mock_webhook_port}", + f"--mock_agent_port={args.mock_agent_port}", + ] + if args.conformance_input: + flag_args.append(f"--conformance_input={args.conformance_input}") + os.environ["FLAGS_conformance_input"] = ( # noqa: SIM112 + args.conformance_input + ) + + os.environ["FLAGS_server_url"] = args.server_url # noqa: SIM112 + if args.simulation_secret: + os.environ["FLAGS_simulation_secret"] = ( # noqa: SIM112 + args.simulation_secret + ) + + flags.FLAGS(flag_args, known_only=True) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command line arguments for the conformance runner.""" + parser = argparse.ArgumentParser( + prog="ucp-conformance", + description="Universal Commerce Protocol Conformance Test Runner", + ) + parser.add_argument( + "--server_url", + default=os.environ.get("UCP_SERVER_URL", "http://localhost:8182"), + help="Target UCP server base URL (default: http://localhost:8182).", + ) + parser.add_argument( + "--platform", + default=None, + help="Platform certification profile name or path (e.g. google).", + ) + parser.add_argument( + "--suite", + default="all", + help="Test suite(s) to run: all, core, common, shopping (default: all).", + ) + parser.add_argument( + "--simulation_secret", + default=os.environ.get("SIMULATION_SECRET", ""), + help="Secret for calling simulation endpoints.", + ) + parser.add_argument( + "--conformance_input", + default=None, + help="Optional path to custom conformance input JSON configuration.", + ) + parser.add_argument( + "--mock_webhook_port", + type=int, + default=8284, + help="Port for local mock webhook server (default: 8284).", + ) + parser.add_argument( + "--mock_agent_port", + type=int, + default=8285, + help="Port for local mock agent profile server (default: 8285).", + ) + parser.add_argument( + "-k", + "--filter", + default=None, + help="Filter expression to select tests by identifier.", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose test execution output.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="List selected test cases without executing them.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Execute the UCP conformance test orchestrator.""" + args = parse_args(argv) + + logging_level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig(level=logging_level, format="%(levelname)s: %(message)s") + + _sync_absl_flags(args) + + sys.stdout.write("=" * 70 + "\n") + sys.stdout.write("UCP CONFORMANCE TEST RUNNER\n") + sys.stdout.write(f"Target Server: {args.server_url}\n") + sys.stdout.write("=" * 70 + "\n") + + server_profile: UcpProfile | None = None + try: + server_profile = fetch_server_profile(args.server_url) + BaseIntegrationTest.server_profile = server_profile + sys.stdout.write("Discovered Server Capabilities:\n") + for cap in server_profile.capabilities.values(): + sys.stdout.write(f" - {cap.name} (v{cap.version})\n") + if server_profile.payment_handlers: + handlers_str = ", ".join(sorted(server_profile.payment_handlers)) + sys.stdout.write(f"Payment Handlers: {handlers_str}\n") + except Exception as exc: # pylint: disable=broad-exception-caught + sys.stdout.write(f"Warning: Failed to fetch /.well-known/ucp: {exc}\n") + + if args.platform: + platform_path = Path(args.platform) + if not platform_path.is_file(): + candidate = Path("platforms") / f"{args.platform}.yaml" + if candidate.is_file(): + platform_path = candidate + if not platform_path.is_file(): + sys.stderr.write( + f"Error: Platform profile not found at {args.platform}\n" + ) + return 1 + + profile = PlatformProfile.from_yaml(platform_path) + sys.stdout.write( + f"\nEvaluating Platform Profile: {profile.platform_name}\n" + ) + if server_profile is None: + sys.stderr.write( + "Error: Cannot validate platform profile: discovery profile" + " unavailable.\n" + ) + return 1 + + errors = profile.validate(server_profile) + if errors: + sys.stderr.write("\nPlatform Certification Failures:\n") + for err in errors: + sys.stderr.write(f" - {err}\n") + return 1 + sys.stdout.write(" Platform requirements satisfied!\n") + + test_specs = _resolve_suites(args.suite) + test_suite = _load_tests(test_specs, filter_pattern=args.filter) + total_tests = test_suite.countTestCases() + + sys.stdout.write( + f"\nResolved {total_tests} test cases across suite '{args.suite}'\n" + ) + + if args.dry_run: + sys.stdout.write("\nDry Run - Selected Tests:\n") + for test in _iter_tests(test_suite): + sys.stdout.write(f" - {test.id()}\n") + return 0 + + sys.stdout.write("-" * 70 + "\n") + runner = unittest.TextTestRunner( + verbosity=2 if args.verbose else 1, stream=sys.stdout + ) + result = runner.run(test_suite) + + passed = result.testsRun - len(result.failures) - len(result.errors) + sys.stdout.write("\n" + "=" * 70 + "\n") + sys.stdout.write("CONFORMANCE TEST SUMMARY\n") + sys.stdout.write(f"Total Executed: {result.testsRun}\n") + sys.stdout.write(f"Passed: {passed}\n") + sys.stdout.write(f"Skipped: {len(result.skipped)}\n") + sys.stdout.write(f"Failures: {len(result.failures)}\n") + sys.stdout.write(f"Errors: {len(result.errors)}\n") + sys.stdout.write("=" * 70 + "\n") + + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/shopping/__init__.py b/shopping/__init__.py new file mode 100644 index 0000000..51f35a5 --- /dev/null +++ b/shopping/__init__.py @@ -0,0 +1,29 @@ +# 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. + +"""UCP Shopping Vertical Plugin (retail & e-commerce).""" + +from shopping.base import ( + ShoppingIntegrationTestBase, + ShoppingTestData, + get_valid_payment_payload, + shopping_test_data, +) + +__all__ = [ + "ShoppingIntegrationTestBase", + "ShoppingTestData", + "get_valid_payment_payload", + "shopping_test_data", +] diff --git a/shopping/base.py b/shopping/base.py new file mode 100644 index 0000000..2fad646 --- /dev/null +++ b/shopping/base.py @@ -0,0 +1,736 @@ +# 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. + +"""Base integration test and payload helpers for UCP Shopping vertical.""" + +import csv +import json +import logging +from pathlib import Path +from typing import Any +import uuid +from absl import flags +from framework.base_test import BaseIntegrationTest +from ucp_sdk.models.schemas import payment_handler +from ucp_sdk.models.schemas.shopping import checkout as f_models +from ucp_sdk.models.schemas.shopping import checkout_create_request +from ucp_sdk.models.schemas.shopping.checkout_update_request import ( + CheckoutUpdateRequest, +) +from ucp_sdk.models.schemas.shopping.types import ( + fulfillment_group_create_request, + fulfillment_method_create_request, + item_create_request, + item_update_request, + line_item_create_request, + line_item_update_request, + shipping_destination, +) + +try: + from ucp_sdk.models.schemas.shopping import ( + payment_create_request, + payment_update_request, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types import ( + payment_create_request, + payment_update_request, + ) + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) + +# Rebuild models to resolve forward references +f_models.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) + + +class UnifiedUpdate(CheckoutUpdateRequest): + """Client-side unified update model to support extensions.""" + + +FLAGS = flags.FLAGS +DEFAULT_SHOPPING_FIXTURES = str( + Path(__file__).resolve().parent / "fixtures" / "flower_shop" +) + +try: + flags.DEFINE_string( + "shopping_fixtures_dir", + DEFAULT_SHOPPING_FIXTURES, + "Directory containing retail CSVs and fixtures.", + ) + flags.DEFINE_string( + "shopping_conformance_input", + str(Path(DEFAULT_SHOPPING_FIXTURES) / "conformance_input.json"), + "Path to retail conformance input JSON.", + ) + flags.DEFINE_string( + "fixture_config", + str(Path(DEFAULT_SHOPPING_FIXTURES) / "test_fixtures.json"), + "Path to test fixtures configuration JSON.", + ) + flags.DEFINE_string( + "conformance_input", + str(Path(DEFAULT_SHOPPING_FIXTURES) / "conformance_input.json"), + "Path to conformance input configuration JSON.", + ) + flags.DEFINE_string( + "test_data_dir", + DEFAULT_SHOPPING_FIXTURES, + "Directory containing test CSV data.", + ) +except flags.DuplicateFlagError: + pass + + +class ShoppingTestData: + """Holder for loaded retail test data.""" + + def __init__(self) -> None: + """Initialize ShoppingTestData container.""" + self.payment_instruments: list[dict[str, Any]] = [] + self.addresses: list[dict[str, Any]] = [] + + def load(self, data_dir: str | Path) -> None: + """Load data from CSV files in the given directory.""" + pi_path = Path(data_dir) / "payment_instruments.csv" + if pi_path.exists(): + with pi_path.open(encoding="utf-8") as f: + self.payment_instruments = list(csv.DictReader(f)) + + addr_path = Path(data_dir) / "addresses.csv" + if addr_path.exists(): + with addr_path.open(encoding="utf-8") as f: + self.addresses = list(csv.DictReader(f)) + + +shopping_test_data = ShoppingTestData() + + +def get_valid_payment_payload( + instrument_id: str = "instr_1", address_id: str = "addr_1" +) -> dict[str, Any]: + """Return a valid payment payload using loaded test data.""" + instr_data = next( + ( + pi + for pi in shopping_test_data.payment_instruments + if pi["id"] == instrument_id + ), + None, + ) + if not instr_data: + instr_data = { + "id": "instr_1", + "type": "card", + "brand": "Visa", + "last_digits": "1234", + "token": "success_token", + "handler_id": "mock_payment_handler", + } + + addr_data = next( + (a for a in shopping_test_data.addresses if a["id"] == address_id), + None, + ) + if not addr_data: + addr_data = { + "street_address": "123 Main St", + "city": "Anytown", + "state": "CA", + "postal_code": "12345", + "country": "US", + } + + billing_address = { + "street_address": addr_data.get("street_address"), + "address_locality": addr_data.get("city"), + "address_region": addr_data.get("state"), + "address_country": addr_data.get("country"), + "postal_code": addr_data.get("postal_code"), + } + + payment_instrument = { + "id": instr_data["id"], + "handler_id": instr_data["handler_id"], + "type": instr_data["type"], + "display": { + "brand": instr_data["brand"], + "last_digits": instr_data["last_digits"], + }, + "credential": {"type": "token", "token": instr_data["token"]}, + "billing_address": billing_address, + } + + return { + "payment": {"instruments": [payment_instrument]}, + "risk_signals": {}, + } + + +class DynamicFixtureContext: + """Context for loading dynamic test fixtures from configuration.""" + + def __init__( + self, + config_path: str | Path | dict[str, Any], + fallback_config: dict[str, Any] | None = None, + ): + """Initialize DynamicFixtureContext with path or dictionary.""" + if isinstance(config_path, (str, Path)): + try: + with Path(config_path).open(encoding="utf-8") as f: + self._config = json.load(f) + except (FileNotFoundError, OSError): + self._config = {} + elif isinstance(config_path, dict): + self._config = config_path + else: + self._config = {} + self._fallback_config = fallback_config or {} + + def get_test_sku(self) -> str: + """Get the test SKU or item ID to use in checkout tests.""" + val = self._config.get("test_sku") + if val is None: + val = self._fallback_config.get("test_sku") + if val is not None: + return str(val) + + val = self._config.get("test_fixtures", {}).get("valid_item", {}).get("sku") + if val is None: + val = ( + self._fallback_config.get("test_fixtures", {}) + .get("valid_item", {}) + .get("sku") + ) + if val is not None: + return str(val) + + items = self._config.get("items", [{}]) + if not items or items == [{}]: + items = self._fallback_config.get("items", [{}]) + if items and isinstance(items, list) and len(items) > 0: + return str(items[0].get("id", "item_1")) + return "item_1" + + def get_test_price(self) -> int: + """Get the expected price for the valid item in minor units.""" + val = self._config.get("test_price") + if val is None: + val = self._fallback_config.get("test_price") + if val is not None: + if isinstance(val, (int, float)): + return int(round(val * 100)) + return int(val) + + val = ( + self._config.get("test_fixtures", {}) + .get("valid_item", {}) + .get("expected_price") + ) + if val is None: + val = ( + self._fallback_config.get("test_fixtures", {}) + .get("valid_item", {}) + .get("expected_price") + ) + if val is not None: + if isinstance(val, (int, float)): + return int(round(val * 100)) + return int(val) + + items = self._config.get("items", [{}]) + if not items or items == [{}]: + items = self._fallback_config.get("items", [{}]) + if items and isinstance(items, list) and len(items) > 0: + return int(items[0].get("price", 3500)) + return 3500 + + def get_test_destination(self) -> dict[str, Any]: + """Get the destination address dictionary for shipping.""" + val = self._config.get("test_destination") + if val is None: + val = self._fallback_config.get("test_destination") + + if val is not None and isinstance(val, dict): + dest = dict(val) + else: + dest = self._config.get("shipping_locations", {}).get( + "domestic_destination", {} + ) + if not dest: + dest = self._fallback_config.get("shipping_locations", {}).get( + "domestic_destination", {} + ) + dest = dict(dest) if dest else {} + + if not dest: + dest = { + "street": "123 Market St", + "city": "San Francisco", + "state": "CA", + "postal_code": "94105", + "country": "US", + } + dest.setdefault("address_country", dest.get("country", "US")) + dest.setdefault("postal_code", dest.get("postal_code", "94105")) + dest.setdefault("locality", dest.get("city", "San Francisco")) + dest.setdefault("region", dest.get("state", "CA")) + dest.setdefault("street_address", dest.get("street", "123 Market St")) + return dest + + def get_test_discount_code(self) -> str: + """Get a valid discount code for tests.""" + val = self._config.get("test_discount_code") + if val is None: + val = self._fallback_config.get("test_discount_code") + if val is not None: + return str(val) + + val = self._config.get("test_fixtures", {}).get("valid_discount_code") + if val is None: + val = self._fallback_config.get("test_fixtures", {}).get( + "valid_discount_code" + ) + if val is not None: + return str(val) + return "SPRING20" + + +ConfiguredFixtureContext = DynamicFixtureContext + + +class ShoppingIntegrationTestBase(BaseIntegrationTest): + """Base integration test class for all UCP Retail Shopping tests.""" + + def setUp(self) -> None: + """Set up shopping fixtures, configs, and endpoints.""" + super().setUp() + + # Verify server advertises shopping checkout + if self.server_profile and not self.server_profile.supports_capability( + "dev.ucp.shopping.checkout" + ): + self.skipTest( + "Server does not support capability: dev.ucp.shopping.checkout" + ) + + # Load conformance input configuration + self.conformance_config = {} + config_path = ( + getattr(FLAGS, "shopping_conformance_input", None) + or getattr(FLAGS, "conformance_input", None) + or str(Path(DEFAULT_SHOPPING_FIXTURES) / "conformance_input.json") + ) + if Path(config_path).exists(): + with Path(config_path).open(encoding="utf-8") as f: + self.conformance_config = json.load(f) + + # Load fixture configuration + fixture_config = {} + fix_cfg_path = getattr(FLAGS, "fixture_config", None) or str( + Path(DEFAULT_SHOPPING_FIXTURES) / "test_fixtures.json" + ) + if fix_cfg_path and Path(fix_cfg_path).exists(): + with Path(fix_cfg_path).open(encoding="utf-8") as f: + fixture_config = json.load(f) + + self.fixture_ctx = DynamicFixtureContext( + fixture_config, fallback_config=self.conformance_config + ) + + # Load CSV test data + fixtures_dir = ( + getattr(FLAGS, "shopping_fixtures_dir", None) + or getattr(FLAGS, "test_data_dir", None) + or DEFAULT_SHOPPING_FIXTURES + ) + if Path(fixtures_dir).exists(): + try: + shopping_test_data.load(fixtures_dir) + except Exception as e: # pylint: disable=broad-exception-caught + logging.warning("Failed to load shopping CSV fixtures: %s", e) + + self._shopping_service_endpoint: str | None = None + + @property + def shopping_service_endpoint(self) -> str: + """Cached property for the shopping service endpoint.""" + if self._shopping_service_endpoint is None: + discovery_resp = self.client.get("/.well-known/ucp") + self.assert_response_status(discovery_resp, 200) + + profile_data = discovery_resp.json() + ucp_data = profile_data.get("ucp", profile_data) + shopping_services = ucp_data.get("services", {}).get( + "dev.ucp.shopping", [] + ) + if not shopping_services: + raise RuntimeError("Shopping service not found in discovery profile") + + shopping_service = ( + shopping_services[0] + if isinstance(shopping_services, list) + else shopping_services + ) + + endpoint = ( + shopping_service.get("endpoint") + if shopping_service and shopping_service.get("transport") == "rest" + else None + ) + if not endpoint: + raise RuntimeError( + "Shopping service endpoint not found in discovery profile" + ) + self._shopping_service_endpoint = str(endpoint) + return self._shopping_service_endpoint + + def get_shopping_url(self, path: str) -> str: + """Construct a full URL for the shopping service.""" + base = self.shopping_service_endpoint.rstrip("/") + path = path.lstrip("/") + return f"{base}/{path}" + + def get_order_url(self, order_id: str) -> str: + """Construct a full URL for an order resource.""" + return self.get_shopping_url(f"/orders/{order_id}") + + def create_checkout_payload( + self, + quantity=1, + item_id: str | None = None, + currency: str | None = None, + handlers=None, + buyer: dict[str, Any] | None = None, + include_fulfillment: bool = True, + ) -> checkout_create_request.CheckoutCreateRequest: + """Create a valid checkout creation payload.""" + ctx = getattr(self, "fixture_ctx", None) or DynamicFixtureContext( + getattr(self, "conformance_config", {}) + ) + + if item_id is None: + item_id = ctx.get_test_sku() + if currency is None: + currency = getattr(self, "conformance_config", {}).get("currency", "USD") + + if handlers is None: + handlers = [ + payment_handler.Base( + id="google_pay", + name="google.pay", + version="2026-04-08", + spec="https://example.com/spec", + config_schema="https://example.com/schema", + instrument_schemas=["https://example.com/instrument_schema"], + config={}, + ) + ] + + item = item_create_request.ItemCreateRequest(id=item_id) + line_item = line_item_create_request.LineItemCreateRequest( + quantity=quantity, item=item + ) + + payment = payment_create_request.PaymentCreateRequest( + instruments=[], + handlers=[h.model_dump(mode="json", exclude_none=True) for h in handlers], + ) + + fulfillment = None + if include_fulfillment: + dest_data = ctx.get_test_destination() + destination = shipping_destination.ShippingDestination( + id="dest_1", + type="shipping_address", + address_country=dest_data.get( + "address_country", dest_data.get("country", "US") + ), + postal_code=dest_data.get("postal_code", "94105"), + locality=dest_data.get("locality", dest_data.get("city")), + region=dest_data.get("region", dest_data.get("state")), + street_address=dest_data.get("street_address", dest_data.get("street")), + ) + group = fulfillment_group_create_request.FulfillmentGroupCreateRequest( + id="group_1", + line_item_ids=["line_item_123"], + selected_option_id="std-ship", + ) + method = fulfillment_method_create_request.FulfillmentMethodCreateRequest( + id="method_1", + type="shipping", + destinations=[destination], + line_item_ids=["line_item_123"], + selected_destination_id="dest_1", + groups=[group], + ) + fulfillment = { + "methods": [ + method.model_dump(mode="json", exclude_none=True, by_alias=True) + ] + } + + item.price = ctx.get_test_price() + line_item.id = "line_item_123" + line_item.totals = [] + + checkout_req = checkout_create_request.CheckoutCreateRequest( + id=str(uuid.uuid4()), + currency=currency, + line_items=[line_item], + payment=payment, + buyer=buyer, + fulfillment=fulfillment, + ) + checkout_req.status = "incomplete" + checkout_req.ucp = {"version": "2026-04-08"} + checkout_req.totals = [] + checkout_req.links = [] + + return checkout_req + + def create_checkout_session( + self, + quantity: int = 1, + item_id: str | None = None, + currency: str | None = None, + handlers: list[Any] | None = None, + buyer: dict[str, Any] | None = None, + select_fulfillment: bool = True, + headers: dict[str, str] | None = None, + ) -> Any: + """Create a checkout session and return the response JSON.""" + create_payload = self.create_checkout_payload( + quantity=quantity, + item_id=item_id, + currency=currency, + handlers=handlers, + buyer=buyer, + include_fulfillment=select_fulfillment, + ) + + request_headers = self.get_headers() + if headers: + request_headers.update(headers) + + response = self.client.post( + self.get_shopping_url("/checkout-sessions"), + json=create_payload.model_dump( + mode="json", by_alias=True, exclude_none=True + ), + headers=request_headers, + ) + self.assert_response_status(response, [200, 201]) + checkout_data = response.json() + + if select_fulfillment: + checkout_data = self.ensure_fulfillment_ready(checkout_data["id"]) + + return checkout_data + + def ensure_fulfillment_ready(self, checkout_id: str) -> Any: + """Ensure a fulfillment option is selected for the checkout.""" + response = self.client.get( + self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), + headers=self.get_headers(), + ) + checkout_data = response.json() + + def is_ready(data): + if not data.get("fulfillment") or not data["fulfillment"].get("methods"): + return False + method = data["fulfillment"]["methods"][0] + if not method.get("selected_destination_id"): + return False + return method.get("groups") and method["groups"][0].get( + "selected_option_id" + ) + + if is_ready(checkout_data): + return checkout_data + + checkout_obj = f_models.Checkout(**checkout_data) + + has_destinations = ( + checkout_data.get("fulfillment") + and checkout_data["fulfillment"].get("methods") + and checkout_data["fulfillment"]["methods"][0].get("destinations") + ) + + if not has_destinations: + address = { + "id": "dest_default", + "street_address": "123 Default St", + "address_locality": "City", + "address_region": "State", + "postal_code": "12345", + "address_country": "US", + } + method_id = None + if checkout_data.get("fulfillment") and checkout_data["fulfillment"].get( + "methods" + ): + method_id = checkout_data["fulfillment"]["methods"][0].get("id") + + method_payload = { + "type": "shipping", + "destinations": [address], + "selected_destination_id": "dest_default", + } + if method_id: + method_payload["id"] = method_id + + checkout_data = self.update_checkout_session( + checkout_obj, + fulfillment={"methods": [method_payload]}, + ) + checkout_obj = f_models.Checkout(**checkout_data) + + method = checkout_data["fulfillment"]["methods"][0] + if not method.get("selected_destination_id") and method.get("destinations"): + dest_id = method["destinations"][0]["id"] + method_payload = method.copy() + method_payload["selected_destination_id"] = dest_id + + checkout_data = self.update_checkout_session( + checkout_obj, + fulfillment={"methods": [method_payload]}, + ) + checkout_obj = f_models.Checkout(**checkout_data) + + method = checkout_data["fulfillment"]["methods"][0] + has_selection = False + if method.get("groups"): + for g in method["groups"]: + if g.get("selected_option_id"): + has_selection = True + break + + if not has_selection and ( + method.get("groups") and method["groups"][0].get("options") + ): + option_id = method["groups"][0]["options"][0]["id"] + method_payload = method.copy() + method_payload["groups"][0]["selected_option_id"] = option_id + + checkout_data = self.update_checkout_session( + checkout_obj, + fulfillment={"methods": [method_payload]}, + ) + + return checkout_data + + def complete_checkout_session( + self, checkout_id: str, payment_payload: dict[str, Any] | None = None + ) -> Any: + """Complete a checkout session.""" + self.ensure_fulfillment_ready(checkout_id) + + if payment_payload is None: + payment_payload = get_valid_payment_payload() + + response = self.client.post( + self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), + json=payment_payload, + headers=self.get_headers(), + ) + self.assert_response_status(response, 200) + return response.json() + + def create_completed_order(self) -> str: + """Orchestrate checkout creation and completion.""" + checkout_data = self.create_checkout_session() + checkout_id = checkout_data["id"] + complete_data = self.complete_checkout_session(checkout_id) + return complete_data["order"]["id"] + + def update_checkout_session( + self, + checkout_obj: Any, + currency: str | None = None, + line_items: list[Any] | None = None, + payment: Any | None = None, + buyer: Any | None = None, + fulfillment: Any | None = None, + discounts: Any | None = None, + platform: Any | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + """Update a checkout session.""" + currency = currency if currency is not None else checkout_obj.currency + + if line_items is None: + line_items = [] + for li in checkout_obj.line_items: + item_update = item_update_request.ItemUpdateRequest( + id=li.item.id, + ) + line_items.append( + line_item_update_request.LineItemUpdateRequest( + id=li.id, + item=item_update, + quantity=li.quantity, + parent_id=li.parent_id, + ) + ) + + if payment is None: + payment = ( + payment_update_request.PaymentUpdateRequest( + instruments=getattr(checkout_obj.payment, "instruments", []), + ) + if checkout_obj.payment + else None + ) + + if isinstance(fulfillment, dict) and "methods" in fulfillment: + for m in fulfillment["methods"]: + if isinstance(m, dict) and "destinations" in m and m["destinations"]: + for d in m["destinations"]: + if isinstance(d, dict) and "type" not in d: + d["type"] = "shipping_address" + + update_payload = UnifiedUpdate( + id=checkout_obj.id, + currency=currency, + line_items=line_items, + payment=payment, + buyer=buyer, + fulfillment=fulfillment, + discounts=discounts, + platform=platform, + ) + + request_headers = self.get_headers() + if headers: + request_headers.update(headers) + + response = self.client.put( + self.get_shopping_url(f"/checkout-sessions/{checkout_obj.id}"), + json=update_payload.model_dump( + mode="json", by_alias=True, exclude_none=True + ), + headers=request_headers, + ) + self.assert_response_status(response, 200) + return response.json() diff --git a/shopping/checkout/__init__.py b/shopping/checkout/__init__.py new file mode 100644 index 0000000..8d44348 --- /dev/null +++ b/shopping/checkout/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Shopping checkout capability test suite.""" diff --git a/shopping/checkout/business_logic_test.py b/shopping/checkout/business_logic_test.py new file mode 100644 index 0000000..3d544b1 --- /dev/null +++ b/shopping/checkout/business_logic_test.py @@ -0,0 +1,314 @@ +# 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. + +"""Business Logic tests for the UCP SDK Server.""" + +from absl.testing import absltest +from framework.decorators import requires_capability +from shopping.base import ShoppingIntegrationTestBase +from ucp_sdk.models.schemas.shopping import buyer_consent as buyer_consent +from ucp_sdk.models.schemas.shopping import ( + checkout_update_request as checkout_update_req, +) +from ucp_sdk.models.schemas.shopping import checkout as checkout + +try: + from ucp_sdk.models.schemas.shopping import payment_update_request +except ImportError: + from ucp_sdk.models.schemas.common.types import payment_update_request + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) +from ucp_sdk.models.schemas.shopping.types import buyer_update_request +from ucp_sdk.models.schemas.shopping.types import item_update_request +from ucp_sdk.models.schemas.shopping.types import line_item_update_request + +# Rebuild models to resolve forward references +checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) + + +@requires_capability("dev.ucp.shopping.checkout") +class BusinessLogicTest(ShoppingIntegrationTestBase): + """Tests for business logic and calculations. + + Validated Paths: + - POST /checkout-sessions + - PUT /checkout-sessions/{id} + - GET /checkout-sessions/{id} + """ + + def test_totals_calculation_on_create(self): + """Test that totals are calculated correctly upon checkout creation. + + Given a request to create a checkout session with a specific item, + When the checkout is created with an incorrect title/price in the request, + Then the server should return a checkout where line item totals, subtotal, + and grand total correctly reflect the database price, ignoring client + input. + """ + # Get expected item details from config + default_item = ( + self.conformance_config.get("items", [{}])[0] + if self.conformance_config + else {} + ) + expected_price = int(default_item.get("price", 3500)) + + # Create checkout (client cannot send title/price per schema). The server + # should use the authoritative price from its DB (which matches our config). + response_json = self.create_checkout_session(select_fulfillment=False) + checkout_obj = checkout.Checkout(**response_json) + + # Verify Line Item Calculations + line_item = checkout_obj.line_items[0] + li_subtotal = next( + (t.amount for t in line_item.totals if t.type == "subtotal"), 0 + ) + li_total = next( + (t.amount for t in line_item.totals if t.type == "total"), 0 + ) + + self.assertEqual( + li_subtotal, + expected_price, + f"Line item subtotal should match DB price {expected_price}", + ) + self.assertEqual( + li_total, + expected_price, + f"Line item total should match DB price {expected_price}", + ) + + # Verify Totals Breakdown + subtotal = next( + (t for t in checkout_obj.totals if t.type == "subtotal"), None + ) + total_obj = next( + (t for t in checkout_obj.totals if t.type == "total"), None + ) + + self.assertIsNotNone(subtotal, "Subtotal missing") + self.assertEqual( + subtotal.amount, + expected_price, + f"Subtotal amount should match DB price {expected_price}", + ) + + self.assertIsNotNone(total_obj, "Total missing") + self.assertEqual( + total_obj.amount, + expected_price, + f"Total amount should match DB price {expected_price}", + ) + + def test_totals_recalculation_on_update(self): + """Test that totals are recalculated correctly upon checkout update. + + Given an existing checkout session with 1 item, + When the line item quantity is updated to 2, + Then the server should return the updated checkout with a total amount of + 2 * price. + """ + response_json = self.create_checkout_session(select_fulfillment=False) + checkout_obj = checkout.Checkout(**response_json) + checkout_id = checkout_obj.id + + # Get expected price from config + expected_price = ( + self.conformance_config.get("items", [{}])[0].get("price", 3500) + if self.conformance_config + else 3500 + ) + expected_price = int(expected_price) + + # Update quantity to 2. Total should be 2 * expected_price. + item_update = item_update_request.ItemUpdateRequest( + id=checkout_obj.line_items[0].item.id, + ) + line_item_update = line_item_update_request.LineItemUpdateRequest( + id=checkout_obj.line_items[0].id, + item=item_update, + quantity=2, + ) + payment_update = payment_update_request.PaymentUpdateRequest( + instruments=checkout_obj.payment.instruments, + ) + + update_payload = checkout_update_req.CheckoutUpdateRequest( + id=checkout_id, + currency=checkout_obj.currency, + line_items=[line_item_update], + payment=payment_update, + ) + + response = self.client.put( + self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), + json=update_payload.model_dump( + mode="json", by_alias=True, exclude_none=True + ), + headers=self.get_headers(), + ) + self.assert_response_status(response, 200) + + updated_checkout = checkout.Checkout(**response.json()) + total_obj = next( + (t for t in updated_checkout.totals if t.type == "total"), None + ) + expected_total = expected_price * 2 + self.assertEqual( + total_obj.amount, + expected_total, + msg=( + "Server did not correct totals on update. Expected" + f" {expected_total}, got {total_obj.amount}" + ), + ) + + def test_buyer_consent(self): + """Test that buyer consent preferences are persisted on creation. + + Given a checkout creation payload including buyer consent preferences + (marketing=True, analytics=False), + When the checkout session is created, + Then the returned checkout object should correctly reflect these consent + values. + """ + create_payload = self.create_checkout_payload() + + # Add consent info + consent_dict = { + "marketing": True, + "analytics": False, + "sale_of_data": False, + } + + create_payload_dict = create_payload.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + create_payload_dict["buyer"] = { + "first_name": "Consent", + "last_name": "Tester", + "email": "consent@example.com", + "consent": consent_dict, + } + + response = self.client.post( + self.get_shopping_url("/checkout-sessions"), + json=create_payload_dict, + headers=self.get_headers(), + ) + self.assert_response_status(response, 201) + checkout_id = checkout.Checkout(**response.json()).id + + response = self.client.get( + self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), + headers=self.get_headers(), + ) + self.assert_response_status(response, 200) + + checkout_obj = checkout.Checkout(**response.json()) + self.assertTrue(checkout_obj.buyer, "Buyer info missing") + + # buyer is types.buyer.Buyer, consent is in extra fields + consent_data = getattr(checkout_obj.buyer, "consent", None) + self.assertTrue(consent_data, "Consent info missing") + + if isinstance(consent_data, dict): + self.assertTrue( + consent_data.get("marketing"), + f"Marketing consent not persisted. Resp: {consent_data}", + ) + self.assertFalse( + consent_data.get("analytics"), + f"Analytics consent not persisted. Resp: {consent_data}", + ) + else: + self.assertTrue( + getattr(consent_data, "marketing", False), + f"Marketing consent not persisted. Resp: {consent_data}", + ) + self.assertFalse( + getattr(consent_data, "analytics", True), + f"Analytics consent not persisted. Resp: {consent_data}", + ) + + def test_buyer_info_persistence(self): + """Test that buyer information is persisted on update. + + Given an existing checkout session, + When the session is updated with new buyer details (email, name), + Then the retrieved checkout session should reflect these updated buyer + details. + """ + response_json = self.create_checkout_session(select_fulfillment=False) + checkout_obj = checkout.Checkout(**response_json) + checkout_id = checkout_obj.id + + # Update with buyer info + item_update = item_update_request.ItemUpdateRequest( + id=checkout_obj.line_items[0].item.id, + ) + line_item_update = line_item_update_request.LineItemUpdateRequest( + id=checkout_obj.line_items[0].id, + item=item_update, + quantity=1, + ) + payment_update = payment_update_request.PaymentUpdateRequest( + instruments=checkout_obj.payment.instruments, + ) + + update_payload = checkout_update_req.CheckoutUpdateRequest( + id=checkout_id, + currency=checkout_obj.currency, + line_items=[line_item_update], + payment=payment_update, + buyer=buyer_update_request.BuyerUpdateRequest( + email="test@example.com", + first_name="Test", + last_name="User", + ), + ) + + response = self.client.put( + self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), + json=update_payload.model_dump( + mode="json", by_alias=True, exclude_none=True + ), + headers=self.get_headers(), + ) + self.assert_response_status(response, 200) + + # GET and verify + response = self.client.get( + self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), + headers=self.get_headers(), + ) + checkout_obj = checkout.Checkout(**response.json()) + self.assertTrue(checkout_obj.buyer, "Buyer info missing") + self.assertEqual( + checkout_obj.buyer.email, "test@example.com", "Email mismatch" + ) + self.assertEqual( + checkout_obj.buyer.first_name, "Test", "First name mismatch" + ) + + +if __name__ == "__main__": + absltest.main() diff --git a/checkout_lifecycle_test.py b/shopping/checkout/lifecycle_test.py similarity index 91% rename from checkout_lifecycle_test.py rename to shopping/checkout/lifecycle_test.py index dbb1283..f15fb59 100644 --- a/checkout_lifecycle_test.py +++ b/shopping/checkout/lifecycle_test.py @@ -15,15 +15,26 @@ """Checkout Lifecycle tests for the UCP SDK Server.""" from absl.testing import absltest -import integration_test_utils +from framework.decorators import requires_capability +from shopping.base import ShoppingIntegrationTestBase, get_valid_payment_payload from ucp_sdk.models.schemas.shopping import ( checkout_update_request as checkout_update_req, ) from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping import payment_update_request -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) + +try: + from ucp_sdk.models.schemas.shopping import payment_update_request +except ImportError: + from ucp_sdk.models.schemas.common.types import payment_update_request + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) from ucp_sdk.models.schemas.shopping.types import item_update_request from ucp_sdk.models.schemas.shopping.types import line_item_update_request @@ -31,7 +42,8 @@ checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) -class CheckoutLifecycleTest(integration_test_utils.IntegrationTestBase): +@requires_capability("dev.ucp.shopping.checkout") +class CheckoutLifecycleTest(ShoppingIntegrationTestBase): """Tests for the lifecycle of a checkout session. Validated Paths: @@ -65,7 +77,7 @@ def test_get_checkout(self): response = self.client.get( self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 200) @@ -118,7 +130,7 @@ def test_update_checkout(self): json=update_payload.model_dump( mode="json", by_alias=True, exclude_none=True ), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 200) @@ -136,7 +148,7 @@ def test_cancel_checkout(self): response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/cancel"), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 200) @@ -161,8 +173,8 @@ def test_complete_checkout(self): response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), - json=integration_test_utils.get_valid_payment_payload(), - headers=integration_test_utils.get_headers(), + json=get_valid_payment_payload(), + headers=self.get_headers(), ) if response.status_code == 409 and "stock" in response.text.lower(): @@ -194,7 +206,7 @@ def _cancel_checkout(self, checkout_id): """Cancel a checkout.""" response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/cancel"), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 200) return response @@ -214,7 +226,7 @@ def test_repeated_cancel(self): response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/cancel"), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) if response.status_code == 200: @@ -286,7 +298,7 @@ def test_cannot_update_canceled_checkout(self): json=update_payload.model_dump( mode="json", by_alias=True, exclude_none=True ), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assertNotEqual( response.status_code, @@ -309,8 +321,8 @@ def test_cannot_complete_canceled_checkout(self): # Try Complete response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), - json=integration_test_utils.get_valid_payment_payload(), - headers=integration_test_utils.get_headers(), + json=get_valid_payment_payload(), + headers=self.get_headers(), ) self.assertNotEqual( response.status_code, @@ -322,8 +334,8 @@ def _complete_checkout(self, checkout_id): """Complete a checkout.""" response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), - json=integration_test_utils.get_valid_payment_payload(), - headers=integration_test_utils.get_headers(), + json=get_valid_payment_payload(), + headers=self.get_headers(), ) self.assert_response_status(response, 200) return response @@ -344,8 +356,8 @@ def test_complete_is_idempotent(self): # Try Complete again (new idempotency key) response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), - json=integration_test_utils.get_valid_payment_payload(), - headers=integration_test_utils.get_headers(), + json=get_valid_payment_payload(), + headers=self.get_headers(), ) self.assertNotEqual( response.status_code, @@ -394,7 +406,7 @@ def test_cannot_update_completed_checkout(self): json=update_payload.model_dump( mode="json", by_alias=True, exclude_none=True ), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assertNotEqual( response.status_code, @@ -417,7 +429,7 @@ def test_cannot_cancel_completed_checkout(self): # Try Cancel response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/cancel"), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assertNotEqual( response.status_code, diff --git a/shopping/discount/__init__.py b/shopping/discount/__init__.py new file mode 100644 index 0000000..7c1a512 --- /dev/null +++ b/shopping/discount/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Shopping discount capability test suite.""" diff --git a/shopping/discount/discount_test.py b/shopping/discount/discount_test.py new file mode 100644 index 0000000..e9a7e03 --- /dev/null +++ b/shopping/discount/discount_test.py @@ -0,0 +1,203 @@ +# 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. + +"""Discount capability tests for UCP Shopping vertical.""" + +from absl.testing import absltest +from framework.decorators import requires_capability +from shopping.base import ShoppingIntegrationTestBase +from ucp_sdk.models.schemas.shopping import checkout as checkout +from ucp_sdk.models.schemas.shopping import discount + + +@requires_capability("dev.ucp.shopping.discount") +class DiscountTest(ShoppingIntegrationTestBase): + """Tests for promotion and discount capabilities in retail checkout. + + Validated Paths: + - PUT /checkout-sessions/{id} + """ + + def test_discount_flow(self): + """Test that valid discount codes decrease the total amount.""" + response_json = self.create_checkout_session(select_fulfillment=False) + checkout_obj = checkout.Checkout(**response_json) + + expected_price = ( + self.conformance_config.get("items", [{}])[0].get("price", 3500) + if self.conformance_config + else 3500 + ) + expected_price = int(expected_price) + + response = self.update_checkout_session( + checkout_obj, discounts={"codes": ["10OFF"]} + ) + discounted_checkout = checkout.Checkout(**response) + expected_total = int(expected_price * 0.9) + + total_obj = next( + (t for t in discounted_checkout.totals if t.type == "total"), None + ) + self.assertIsNotNone(total_obj, "Total object missing") + self.assertEqual( + total_obj.amount, + expected_total, + msg=( + f"Discount not applied correctly. Expected {expected_total}, got" + f" {total_obj.amount}" + ), + ) + + discounts_data = getattr(discounted_checkout, "discounts", {}) + discounts_obj = ( + discount.DiscountsObject(**discounts_data) if discounts_data else None + ) + self.assertTrue( + discounts_obj and discounts_obj.applied, + "Applied discounts field missing", + ) + self.assertEqual( + discounts_obj.applied[0].code, + "10OFF", + "Applied discounts field incorrect", + ) + + def test_multiple_discounts_accepted(self): + """Test that multiple valid discount codes are both applied.""" + response_json = self.create_checkout_session(select_fulfillment=False) + checkout_obj = checkout.Checkout(**response_json) + + expected_price = ( + self.conformance_config.get("items", [{}])[0].get("price", 3500) + if self.conformance_config + else 3500 + ) + expected_price = int(expected_price) + + response_json = self.update_checkout_session( + checkout_obj, discounts={"codes": ["10OFF", "WELCOME20"]} + ) + discounted_checkout = checkout.Checkout(**response_json) + expected_total = int(int(expected_price * 0.9) * 0.8) + + total_obj = next( + (t for t in discounted_checkout.totals if t.type == "total"), None + ) + self.assertEqual( + total_obj.amount, + expected_total, + f"Multiple discounts failed. Exp {expected_total}, got" + f" {total_obj.amount}", + ) + + discounts_data = getattr(discounted_checkout, "discounts", {}) + discounts_obj = ( + discount.DiscountsObject(**discounts_data) if discounts_data else None + ) + self.assertTrue(discounts_obj and len(discounts_obj.applied) == 2) + applied_codes = [d.code for d in discounts_obj.applied] + self.assertIn("10OFF", applied_codes) + self.assertIn("WELCOME20", applied_codes) + + def test_multiple_discounts_one_rejected(self): + """Test requesting multiple discounts where one is valid and one is not.""" + response_json = self.create_checkout_session(select_fulfillment=False) + checkout_obj = checkout.Checkout(**response_json) + + expected_price = ( + self.conformance_config.get("items", [{}])[0].get("price", 3500) + if self.conformance_config + else 3500 + ) + expected_price = int(expected_price) + + response_json = self.update_checkout_session( + checkout_obj, discounts={"codes": ["10OFF", "INVALID_CODE"]} + ) + discounted_checkout = checkout.Checkout(**response_json) + expected_total = int(expected_price * 0.9) + + total_obj = next( + (t for t in discounted_checkout.totals if t.type == "total"), None + ) + self.assertEqual(total_obj.amount, expected_total) + + discounts_data = getattr(discounted_checkout, "discounts", {}) + discounts_obj = ( + discount.DiscountsObject(**discounts_data) if discounts_data else None + ) + self.assertTrue(discounts_obj and len(discounts_obj.applied) == 1) + self.assertEqual(discounts_obj.applied[0].code, "10OFF") + + def test_fixed_amount_discount(self): + """Test that a fixed-amount discount code decreases the total correctly.""" + response_json = self.create_checkout_session(select_fulfillment=False) + checkout_obj = checkout.Checkout(**response_json) + + expected_price = ( + self.conformance_config.get("items", [{}])[0].get("price", 3500) + if self.conformance_config + else 3500 + ) + expected_price = int(expected_price) + + response_json = self.update_checkout_session( + checkout_obj, discounts={"codes": ["FIXED500"]} + ) + discounted_checkout = checkout.Checkout(**response_json) + expected_total = expected_price - 500 + + total_obj = next( + (t for t in discounted_checkout.totals if t.type == "total"), None + ) + self.assertIsNotNone(total_obj, "Total object missing") + self.assertEqual( + total_obj.amount, + expected_total, + msg=( + f"Fixed discount failed. Exp {expected_total}, got {total_obj.amount}" + ), + ) + + discounts_data = getattr(discounted_checkout, "discounts", {}) + discounts_obj = ( + discount.DiscountsObject(**discounts_data) if discounts_data else None + ) + self.assertTrue( + discounts_obj and discounts_obj.applied, + "Applied discounts field missing", + ) + self.assertEqual(discounts_obj.applied[0].code, "FIXED500") + self.assertEqual(discounts_obj.applied[0].amount, 500) + + def test_unknown_discount_code(self): + """Test that unknown discount codes are ignored.""" + response_json = self.create_checkout_session() + checkout_obj = checkout.Checkout(**response_json) + + resp_json = self.update_checkout_session( + checkout_obj, discounts={"codes": ["INVALID_CODE_123"]} + ) + updated_checkout = checkout.Checkout(**resp_json) + discount_total = next( + (t for t in updated_checkout.totals if t.type == "discount"), None + ) + self.assertIsNone( + discount_total, "Unknown discount code should not apply discount" + ) + + +if __name__ == "__main__": + absltest.main() diff --git a/test_data/flower_shop/addresses.csv b/shopping/fixtures/flower_shop/addresses.csv similarity index 100% rename from test_data/flower_shop/addresses.csv rename to shopping/fixtures/flower_shop/addresses.csv diff --git a/test_data/flower_shop/conformance_input.json b/shopping/fixtures/flower_shop/conformance_input.json similarity index 100% rename from test_data/flower_shop/conformance_input.json rename to shopping/fixtures/flower_shop/conformance_input.json diff --git a/test_data/flower_shop/customers.csv b/shopping/fixtures/flower_shop/customers.csv similarity index 100% rename from test_data/flower_shop/customers.csv rename to shopping/fixtures/flower_shop/customers.csv diff --git a/test_data/flower_shop/discounts.csv b/shopping/fixtures/flower_shop/discounts.csv similarity index 100% rename from test_data/flower_shop/discounts.csv rename to shopping/fixtures/flower_shop/discounts.csv diff --git a/test_data/flower_shop/inventory.csv b/shopping/fixtures/flower_shop/inventory.csv similarity index 100% rename from test_data/flower_shop/inventory.csv rename to shopping/fixtures/flower_shop/inventory.csv diff --git a/test_data/flower_shop/payment_instruments.csv b/shopping/fixtures/flower_shop/payment_instruments.csv similarity index 100% rename from test_data/flower_shop/payment_instruments.csv rename to shopping/fixtures/flower_shop/payment_instruments.csv diff --git a/test_data/flower_shop/products.csv b/shopping/fixtures/flower_shop/products.csv similarity index 100% rename from test_data/flower_shop/products.csv rename to shopping/fixtures/flower_shop/products.csv diff --git a/test_data/flower_shop/promotions.csv b/shopping/fixtures/flower_shop/promotions.csv similarity index 100% rename from test_data/flower_shop/promotions.csv rename to shopping/fixtures/flower_shop/promotions.csv diff --git a/test_data/flower_shop/shipping_rates.csv b/shopping/fixtures/flower_shop/shipping_rates.csv similarity index 100% rename from test_data/flower_shop/shipping_rates.csv rename to shopping/fixtures/flower_shop/shipping_rates.csv diff --git a/shopping/fixtures/flower_shop/test_fixtures.json b/shopping/fixtures/flower_shop/test_fixtures.json new file mode 100644 index 0000000..46cd31f --- /dev/null +++ b/shopping/fixtures/flower_shop/test_fixtures.json @@ -0,0 +1,20 @@ +{ + "test_fixtures": { + "valid_item": { + "sku": "bouquet_roses", + "expected_price": 35.0, + "quantity": 1 + }, + "valid_discount_code": "SPRING20", + "expected_discount_reduction": 5.0 + }, + "shipping_locations": { + "domestic_destination": { + "street": "123 Market St", + "city": "San Francisco", + "state": "CA", + "postal_code": "94105", + "country": "US" + } + } +} diff --git a/shopping/fulfillment/__init__.py b/shopping/fulfillment/__init__.py new file mode 100644 index 0000000..c1240ce --- /dev/null +++ b/shopping/fulfillment/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Shopping fulfillment capability test suite.""" diff --git a/fulfillment_test.py b/shopping/fulfillment/fulfillment_test.py similarity index 92% rename from fulfillment_test.py rename to shopping/fulfillment/fulfillment_test.py index 3d20c89..c480687 100644 --- a/fulfillment_test.py +++ b/shopping/fulfillment/fulfillment_test.py @@ -16,18 +16,30 @@ import uuid from absl.testing import absltest -import integration_test_utils +from framework.decorators import requires_capability +from shopping.base import shopping_test_data +from shopping.base import ShoppingIntegrationTestBase from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) -from ucp_sdk.models.schemas.shopping.types import postal_address + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) +try: + from ucp_sdk.models.schemas.shopping.types import postal_address +except ImportError: + from ucp_sdk.models.schemas.common.types import postal_address # Rebuild models to resolve forward references checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) -class FulfillmentTest(integration_test_utils.IntegrationTestBase): +@requires_capability("dev.ucp.shopping.fulfillment") +class FulfillmentTest(ShoppingIntegrationTestBase): """Tests for fulfillment logic. Validated Paths: @@ -49,15 +61,31 @@ def test_fulfillment_flow(self) -> None: # 1. Update with Fulfillment Address # We explicitly construct the extended fulfillment payload here because # the helper method assumes a flatter, older structure. - # Use helper to get a valid address from CSV - addr_data = integration_test_utils.test_data.addresses[0] + # Use dynamic destination address from fixture_ctx or CSV fallback + ctx = getattr(self, "fixture_ctx", None) + dest_data = ctx.get_test_destination() if ctx else None + if not dest_data: + addr_csv = shopping_test_data.addresses[0] + dest_data = { + "street": addr_csv["street_address"], + "city": addr_csv["city"], + "state": addr_csv["state"], + "postal_code": addr_csv["postal_code"], + "country": addr_csv["country"], + } address = postal_address.PostalAddress( full_name="John Doe", - street_address=addr_data["street_address"], - address_locality=addr_data["city"], - address_region=addr_data["state"], - postal_code=addr_data["postal_code"], - address_country=addr_data["country"], + street_address=dest_data.get( + "street_address", dest_data.get("street", "123 Market St") + ), + address_locality=dest_data.get( + "locality", dest_data.get("city", "San Francisco") + ), + address_region=dest_data.get("region", dest_data.get("state", "CA")), + postal_code=dest_data.get("postal_code", "94105"), + address_country=dest_data.get( + "address_country", dest_data.get("country", "US") + ), ) # Construct fulfillment payload @@ -147,7 +175,7 @@ def test_dynamic_fulfillment(self) -> None: # 1. Update with US Address # addr_1 is US in CSV - addr_data = integration_test_utils.test_data.addresses[0] + addr_data = shopping_test_data.addresses[0] us_address = { "id": "dest_us", "address_country": addr_data["country"], @@ -543,7 +571,7 @@ def test_free_shipping_on_expensive_order(self) -> None: checkout_obj = checkout.Checkout(**response_json) # addr_1 is US in CSV - addr_data = integration_test_utils.test_data.addresses[0] + addr_data = shopping_test_data.addresses[0] address = { "id": "dest_us", "address_country": addr_data["country"], @@ -593,7 +621,7 @@ def test_free_shipping_for_specific_item(self) -> None: checkout_obj = checkout.Checkout(**response_json) # addr_1 is US in CSV - addr_data = integration_test_utils.test_data.addresses[0] + addr_data = shopping_test_data.addresses[0] address = { "id": "dest_us", "address_country": addr_data["country"], diff --git a/shopping/order/__init__.py b/shopping/order/__init__.py new file mode 100644 index 0000000..155a503 --- /dev/null +++ b/shopping/order/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Shopping order capability test suite.""" diff --git a/order_test.py b/shopping/order/order_test.py similarity index 93% rename from order_test.py rename to shopping/order/order_test.py index b86dc68..e5ba232 100644 --- a/order_test.py +++ b/shopping/order/order_test.py @@ -18,13 +18,22 @@ import uuid from absl import flags from absl.testing import absltest -import integration_test_utils +from framework.decorators import requires_capability from pydantic import AnyUrl +from shopping.base import get_valid_payment_payload +from shopping.base import shopping_test_data +from shopping.base import ShoppingIntegrationTestBase from ucp_sdk.models.schemas.shopping import checkout as checkout from ucp_sdk.models.schemas.shopping import order -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) from ucp_sdk.models.schemas.shopping.types import adjustment from ucp_sdk.models.schemas.shopping.types import fulfillment_event @@ -34,7 +43,8 @@ FLAGS = flags.FLAGS -class OrderTest(integration_test_utils.IntegrationTestBase): +@requires_capability("dev.ucp.shopping.order") +class OrderTest(ShoppingIntegrationTestBase): """Tests for order management. Validated Paths: @@ -81,9 +91,10 @@ def test_order_fulfillment_retrieval(self) -> None: # Update with Address to get options # Use helper to get a valid address from CSV - address_data = integration_test_utils.test_data.addresses[0] + address_data = shopping_test_data.addresses[0] fulfillment_address = { "id": "dest_manual", + "type": "shipping_address", "full_name": "Jane Doe", "street_address": address_data["street_address"], "address_locality": address_data["city"], @@ -115,7 +126,7 @@ def test_order_fulfillment_retrieval(self) -> None: } for li in checkout_obj.line_items ], - "payment": integration_test_utils.get_valid_payment_payload(), + "payment": get_valid_payment_payload(), "fulfillment": fulfillment_payload, } @@ -197,9 +208,10 @@ def test_order_update(self) -> None: # Update with Address to get options # Use helper to get a valid address from CSV - address_data = integration_test_utils.test_data.addresses[0] + address_data = shopping_test_data.addresses[0] addr = { "id": "dest_manual_2", + "type": "shipping_address", "full_name": "Jane Doe", "street_address": address_data["street_address"], "address_locality": address_data["city"], @@ -231,7 +243,7 @@ def test_order_update(self) -> None: } for li in checkout_obj.line_items ], - "payment": integration_test_utils.get_valid_payment_payload(), + "payment": get_valid_payment_payload(), "fulfillment": fulfillment_payload, } diff --git a/shopping/validation/__init__.py b/shopping/validation/__init__.py new file mode 100644 index 0000000..ee51751 --- /dev/null +++ b/shopping/validation/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Shopping input validation and boundary test suite.""" diff --git a/invalid_input_test.py b/shopping/validation/invalid_input_test.py similarity index 70% rename from invalid_input_test.py rename to shopping/validation/invalid_input_test.py index d0d9e81..3b55c90 100644 --- a/invalid_input_test.py +++ b/shopping/validation/invalid_input_test.py @@ -17,22 +17,29 @@ import datetime import uuid from absl.testing import absltest -import integration_test_utils +from framework.decorators import requires_capability +from shopping.base import ShoppingIntegrationTestBase from ucp_sdk.models.schemas.shopping import checkout as checkout from ucp_sdk.models.schemas.shopping import order -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) + +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) # Rebuild models to resolve forward references checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) -class InvalidInputTest(integration_test_utils.IntegrationTestBase): +@requires_capability("dev.ucp.shopping.order") +class InvalidInputTest(ShoppingIntegrationTestBase): """Tests for invalid inputs and schema validation. Validated Paths: - - PUT /checkout-sessions/{id} - GET /orders/{id} - PUT /orders/{id} """ @@ -49,7 +56,7 @@ def test_invalid_adjustment_status(self): # Get Order response = self.client.get( - f"/orders/{order_id}", headers=self.get_headers() + self.get_order_url(order_id), headers=self.get_headers() ) order_obj = order.Order(**response.json()) order_dict = order_obj.model_dump( @@ -71,39 +78,13 @@ def test_invalid_adjustment_status(self): # Update Order resp = self.client.put( - f"/orders/{order_id}", + self.get_order_url(order_id), json=order_dict, headers=self.get_headers(), ) # Pydantic validation error should result in 422 self.assert_response_status(resp, 422) - def test_unknown_discount_code(self): - """Test that unknown discount codes are ignored. - - Given an existing checkout session, - When an update request includes an unknown discount code, - Then the request should succeed (200 OK) but no discount should be applied - to the totals. - """ - response_json = self.create_checkout_session() - checkout_obj = checkout.Checkout(**response_json) - - # Update with unknown discount code using helper - # The helper preserves existing fields, so we just pass the discount - resp_json = self.update_checkout_session( - checkout_obj, discounts={"codes": ["INVALID_CODE_123"]} - ) - - updated_checkout = checkout.Checkout(**resp_json) - # Verify no discount applied - discount_total = next( - (t for t in updated_checkout.totals if t.type == "discount"), None - ) - self.assertIsNone( - discount_total, "Unknown discount code should not apply discount" - ) - def test_malformed_adjustment_payload(self): """Test that malformed adjustment payloads are rejected. @@ -116,7 +97,7 @@ def test_malformed_adjustment_payload(self): # Get Order response = self.client.get( - f"/orders/{order_id}", headers=self.get_headers() + self.get_order_url(order_id), headers=self.get_headers() ) order_obj = order.Order(**response.json()) order_dict = order_obj.model_dump( @@ -128,7 +109,7 @@ def test_malformed_adjustment_payload(self): # Update Order resp = self.client.put( - f"/orders/{order_id}", + self.get_order_url(order_id), json=order_dict, headers=self.get_headers(), ) diff --git a/validation_test.py b/shopping/validation/validation_test.py similarity index 88% rename from validation_test.py rename to shopping/validation/validation_test.py index 77a5301..9934949 100644 --- a/validation_test.py +++ b/shopping/validation/validation_test.py @@ -15,15 +15,26 @@ """Validation tests for the UCP SDK Server.""" from absl.testing import absltest -import integration_test_utils +from framework.decorators import requires_capability +from shopping.base import get_valid_payment_payload +from shopping.base import ShoppingIntegrationTestBase from ucp_sdk.models.schemas.shopping import ( checkout_update_request as checkout_update_req, ) from ucp_sdk.models.schemas.shopping import checkout as checkout -from ucp_sdk.models.schemas.shopping import payment_update_request -from ucp_sdk.models.schemas.shopping.payment import ( - Payment, -) + +try: + from ucp_sdk.models.schemas.shopping import payment_update_request +except ImportError: + from ucp_sdk.models.schemas.common.types import payment_update_request +try: + from ucp_sdk.models.schemas.shopping.payment import ( + Payment, + ) +except ImportError: + from ucp_sdk.models.schemas.common.types.payment import ( + Payment, + ) from ucp_sdk.models.schemas.shopping.types import item_update_request from ucp_sdk.models.schemas.shopping.types import line_item_update_request @@ -32,7 +43,8 @@ checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) -class ValidationTest(integration_test_utils.IntegrationTestBase): +@requires_capability("dev.ucp.shopping.checkout") +class ValidationTest(ShoppingIntegrationTestBase): """Tests for input validation and error handling. Validated Paths: @@ -64,7 +76,7 @@ def test_out_of_stock(self) -> None: json=create_payload.model_dump( mode="json", by_alias=True, exclude_none=True ), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 400) @@ -115,7 +127,7 @@ def test_update_inventory_validation(self) -> None: json=update_payload.model_dump( mode="json", by_alias=True, exclude_none=True ), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 400) @@ -145,7 +157,7 @@ def test_product_not_found(self) -> None: json=create_payload.model_dump( mode="json", by_alias=True, exclude_none=True ), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 400) @@ -166,14 +178,12 @@ def test_payment_failure(self) -> None: # Use the helper to get valid structure, but request the failing instrument # 'instr_fail' is loaded from payment_instruments.csv - payment_payload = integration_test_utils.get_valid_payment_payload( - instrument_id="instr_fail" - ) + payment_payload = get_valid_payment_payload(instrument_id="instr_fail") response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), json=payment_payload, - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 402) @@ -188,12 +198,12 @@ def test_complete_without_fulfillment(self) -> None: response_json = self.create_checkout_session(select_fulfillment=False) checkout_id = response_json["id"] - payment_payload = integration_test_utils.get_valid_payment_payload() + payment_payload = get_valid_payment_payload() response = self.client.post( self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"), json=payment_payload, - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 400) @@ -226,7 +236,7 @@ def test_structured_error_messages(self) -> None: json=create_payload.model_dump( mode="json", by_alias=True, exclude_none=True ), - headers=integration_test_utils.get_headers(), + headers=self.get_headers(), ) self.assert_response_status(response, 400) diff --git a/test_data/flower_shop b/test_data/flower_shop new file mode 120000 index 0000000..3da9a88 --- /dev/null +++ b/test_data/flower_shop @@ -0,0 +1 @@ +../shopping/fixtures/flower_shop \ No newline at end of file