From c31eb3429260b94296e76a41c95138fb5c5a754c Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 14:06:47 +0000 Subject: [PATCH 01/15] feat: include buyer and fulfillment address in update_checkout test Updates the `test_update_checkout` in `checkout_lifecycle_test.py` to include `buyer` and `fulfillment` (with updated shipping destination) in the update request payload. Also asserts that the response contains the updated buyer details and the new selected destination. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- checkout_lifecycle_test.py | 79 +++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/checkout_lifecycle_test.py b/checkout_lifecycle_test.py index dbb1283..2b38375 100644 --- a/checkout_lifecycle_test.py +++ b/checkout_lifecycle_test.py @@ -24,8 +24,16 @@ 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 ( + fulfillment_group_update_request, +) +from ucp_sdk.models.schemas.shopping.types import ( + fulfillment_method_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 +from ucp_sdk.models.schemas.shopping.types import shipping_destination # Rebuild models to resolve forward references checkout.Checkout.model_rebuild(_types_namespace={"Payment": Payment}) @@ -81,7 +89,7 @@ def test_update_checkout(self): Given an existing checkout session, When a PUT request is sent to /checkout-sessions/{id} with updated line - items, + items, buyer, and fulfillment address, Then the response should be 200 OK and reflect the updates. """ response_json = self.create_checkout_session() @@ -106,11 +114,57 @@ def test_update_checkout(self): ], ) + buyer_update = buyer_update_request.BuyerUpdateRequest( + email="jane.doe@example.com", + first_name="Jane", + last_name="Doe", + phone_number="+15555555556", + ) + + new_destination = shipping_destination.ShippingDestination( + id="dest_2", + address_country="US", + postal_code="90210", + locality="Beverly Hills", + region="CA", + street_address="456 Elm St", + ) + + existing_method = checkout_obj.fulfillment["methods"][0] + existing_group = existing_method["groups"][0] + + group_update = ( + fulfillment_group_update_request.FulfillmentGroupUpdateRequest( + id=existing_group["id"], + selected_option_id=existing_group["selected_option_id"], + line_item_ids=existing_group["line_item_ids"], + ) + ) + + method_update = ( + fulfillment_method_update_request.FulfillmentMethodUpdateRequest( + id=existing_method["id"], + type="shipping", + destinations=[new_destination], + selected_destination_id="dest_2", + line_item_ids=[li.id for li in checkout_obj.line_items], + groups=[group_update], + ) + ) + + fulfillment_update = { + "methods": [ + method_update.model_dump(mode="json", exclude_none=True, by_alias=True) + ] + } + update_payload = checkout_update_req.CheckoutUpdateRequest( id=checkout_id, currency=checkout_obj.currency, line_items=[line_item_update], payment=payment_update, + buyer=buyer_update, + fulfillment=fulfillment_update, ) response = self.client.put( @@ -123,6 +177,29 @@ def test_update_checkout(self): self.assert_response_status(response, 200) + # Verify updates in the response + resp_json = response.json() + self.assertEqual( + resp_json.get("buyer", {}).get("email"), "jane.doe@example.com" + ) + self.assertEqual(resp_json.get("buyer", {}).get("first_name"), "Jane") + self.assertEqual(resp_json.get("buyer", {}).get("last_name"), "Doe") + + fulfillment_resp = resp_json.get("fulfillment", {}) + self.assertTrue(fulfillment_resp, "Fulfillment missing in response") + method_resp = fulfillment_resp.get("methods", [{}])[0] + self.assertEqual(method_resp.get("selected_destination_id"), "dest_2") + + destinations = method_resp.get("destinations", []) + selected_dest = next( + (d for d in destinations if d.get("id") == "dest_2"), None + ) + self.assertIsNotNone( + selected_dest, "Selected destination not found in response destinations" + ) + self.assertEqual(selected_dest.get("postal_code"), "90210") + self.assertEqual(selected_dest.get("street_address"), "456 Elm St") + def test_cancel_checkout(self): """Test successful checkout cancellation. From f80f44a3157153411cafbeac502e3ebefd175b3e Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 14:11:53 +0000 Subject: [PATCH 02/15] docs: prioritize custom server testing in README Rewrites the README to focus on how to configure and run conformance tests against any custom UCP server implementation. Moves the Python sample server instructions to an example walkthrough section. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- README.md | 199 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 139 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index a24947c..a4e03a7 100644 --- a/README.md +++ b/README.md @@ -14,39 +14,144 @@ 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 suite contains language-agnostic integration tests that run against a running UCP Merchant Server instance. It verifies the server's adherence to the [Universal Commerce Protocol (UCP) specification](https://github.com/Universal-Commerce-Protocol/ucp). + +## Testing a Custom Server (Agnostic) + +The conformance tests are designed to be run against _any_ UCP server implementation. To run tests against your own server, follow these steps: + +### 1. Seed Your Server with Test Data + +Your server must be populated with test data (products, inventory, shipping rates, discounts) that match the expectations defined in your test configuration. + +You can use the default [flower_shop test data](test_data/flower_shop) as a reference for what data is needed. + +### 2. Configure Conformance Input (`conformance_input.json`) + +Create a `conformance_input.json` file to define your merchant-specific values and expectations. The tests use this file to know what to assert against. + +Example `conformance_input.json`: + +```json +{ + "ucp_version": "2026-04-08", + "required_capabilities": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.order" + ], + "currency": "USD", + "items": [ + { + "id": "item_1", + "title": "Valid Item", + "price": 1000 + } + ], + "out_of_stock_item": { + "id": "item_out_of_stock", + "title": "Out of Stock Item" + }, + "non_existent_item": { + "id": "non_existent", + "title": "Non-existent Item" + } +} +``` -## Prerequisites +- `ucp_version`: The spec version your server targets. +- `required_capabilities`: The capability names your server is expected to declare in discovery. +- `items`: List of items available on your server. The first item in this list will be used for standard checkout tests. +- `out_of_stock_item`: An item ID that should trigger an out-of-stock error (status `409`) when trying to complete checkout. +- `non_existent_item`: An item ID that does not exist in your catalog, used to test invalid input handling. + +### 3. Configure Test Fixtures (`test_fixtures.json`) + +If your server's test data differs from the default, create a `test_fixtures.json` to map the exact values for calculations and assertions. + +Example `test_fixtures.json`: + +```json +{ + "test_fixtures": { + "valid_item": { + "sku": "item_1", + "expected_price": 10.0, + "quantity": 1 + }, + "valid_discount_code": "PROMO10", + "expected_discount_reduction": 1.0 + }, + "shipping_locations": { + "domestic_destination": { + "street": "123 Main St", + "city": "Anytown", + "state": "CA", + "postal_code": "12345", + "country": "US" + } + } +} +``` -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. +- `valid_item`: The SKU and expected price (in major units, e.g., `10.00`) of the item used in tests. +- `valid_discount_code` & `expected_discount_reduction`: A discount code that should apply successfully and the expected reduction amount (in major units). +- `shipping_locations`: Addresses used for fulfillment tests. -NOTE: These instructions assume the commands are executed from the directory -containing this README. +### 4. Run the Tests -### Updating dependencies +Install dependencies: ```bash uv sync +``` -uv sync --directory ../samples/rest/python/server/ +Run the tests pointing to your server: -uv sync --directory ../sdk/python/ +```bash +SERVER_URL=https://your-merchant-server.com \ +SIMULATION_SECRET=your-sim-secret \ +uv run pytest \ + --conformance_input=path/to/your/conformance_input.json \ + --fixture_config=path/to/your/test_fixtures.json ``` -### Initializing the database +Alternatively, you can run individual test files and pass arguments: ```bash -DATABASE_PATH=/tmp/ucp_test +uv run checkout_lifecycle_test.py \ + --server_url=https://your-merchant-server.com \ + --simulation_secret=your-sim-secret \ + --conformance_input=path/to/your/conformance_input.json \ + --fixture_config=path/to/your/test_fixtures.json +``` + +--- -rm -rf ${DATABASE_PATH} -mkdir ${DATABASE_PATH} +## Example Walkthrough: Running against the Python Sample Server + +For a quick demonstration or local development, you can run the suite against the reference Python sample server included in this repository. + +NOTE: These instructions assume the commands are executed from the directory containing this README. + +### Prerequisites + +Sync dependencies for the test suite, the sample server, and the SDK: + +```bash +uv sync +uv sync --directory ../samples/rest/python/server/ +uv sync --directory ../python-sdk/ +``` + +### 1. Initialize the Sample Database + +Populate the SQLite databases with the default "flower shop" test data: + +```bash +DATABASE_PATH=/tmp/ucp_test +rm -rf ${DATABASE_PATH} && mkdir -p ${DATABASE_PATH} uv run --directory ../samples/rest/python/server import_csv.py \ --products_db_path=${DATABASE_PATH}/products.db \ @@ -54,7 +159,9 @@ uv run --directory ../samples/rest/python/server import_csv.py \ --data_dir=../../../../conformance/test_data/flower_shop ``` -Starting the server: +### 2. Start the Sample Server + +Start the server in the background: ```bash SIMULATION_SECRET=super-secret-sim-key @@ -68,62 +175,34 @@ uv run --directory ../samples/rest/python/server server.py \ MERCHANT_SERVER_PID=$! ``` -## Running the Tests +### 3. Run the Tests + +Run the tests using the default flower shop configurations: ```bash -for test_file in *_test.py; do -uv run ${test_file} \ - --server_url=http://localhost:${MERCHANT_SERVER_PORT} \ - --simulation_secret=${SIMULATION_SECRET} \ - --conformance_input=test_data/flower_shop/conformance_input.json \ - --fixture_config=test_data/flower_shop/test_fixtures.json -done - -# Or, if you prefer: -SERVER_URL=http://localhost:${MERCHANT_SERVER_PORT} SIMULATION_SECRET=${SIMULATION_SECRET} uv run pytest +SERVER_URL=http://localhost:${MERCHANT_SERVER_PORT} \ +SIMULATION_SECRET=${SIMULATION_SECRET} \ +uv run pytest ``` -### Customizing Test Fixtures +### 4. Clean Up -You can customize the test fixtures (SKU, expected pricing, discount codes, shipping destinations) by editing `test_fixtures.json` or passing a custom configuration file using the `--fixture_config` flag. - -## Conformance input - -`conformance_input.json` carries the merchant-specific values the tests assert -against, so the suite is not hardwired to the Flower Shop sample: - -- `ucp_version` — the spec release the merchant targets; `protocol_test` - asserts the discovery profile and shopping service declare exactly this - version. When omitted, only the universal structural rule (date-based - `YYYY-MM-DD` version) is enforced. -- `required_capabilities` — capability names this merchant is expected to - declare in discovery. Capability sets are negotiated per merchant, so list - the ones your implementation ships; capability _names_ are always validated - against the reverse-DNS convention regardless. - -Version negotiation uses the version the server advertises in discovery as the -compatible header value (with an obviously-future literal for the incompatible -case), so it stays correct across spec releases. - -## Cleaning Up - -Terminate the server using: +Stop the background server: ```bash kill ${MERCHANT_SERVER_PID} ``` -## Examining the database state +### Examining Database State (Optional) -After running tests, one can examine the database state using the -`dump_transactions` and `dump_log` tools: +After running tests, you can inspect the sample server's database state: ```bash +# Dump transactions table uv run --directory ../samples/rest/python/server dump_transactions.py \ --transactions_db_path=${DATABASE_PATH}/transactions.db -``` -```bash +# Dump request logs uv run --directory ../samples/rest/python/server dump_log.py \ --transactions_db_path=${DATABASE_PATH}/transactions.db ``` From 02dba61d6cfff2028c26357ffd144994e860126f Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 14:14:55 +0000 Subject: [PATCH 03/15] docs: remove redundant uv sync command for python-sdk from README The `python-sdk` is already configured as an editable dependency in `conformance/pyproject.toml`, so `uv sync` in the `conformance` directory will automatically install it. Syncing it separately is redundant for running the tests. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index a4e03a7..132d73a 100644 --- a/README.md +++ b/README.md @@ -137,12 +137,11 @@ NOTE: These instructions assume the commands are executed from the directory con ### Prerequisites -Sync dependencies for the test suite, the sample server, and the SDK: +Sync dependencies for the test suite and the sample server: ```bash uv sync uv sync --directory ../samples/rest/python/server/ -uv sync --directory ../python-sdk/ ``` ### 1. Initialize the Sample Database From 9333b3b775486f6a872349428ee7ea261d10e20e Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 14:21:52 +0000 Subject: [PATCH 04/15] test: pass buyer details to complete checkout in conformance tests Some servers (like ucp.business-store.dev) strictly require buyer details (email, name) to be present before a checkout can be completed. This updates the tests to pass a default buyer when creating sessions for completion tests. Also adds test data config for business-store.dev. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- checkout_lifecycle_test.py | 15 +++++++---- .../business_store/conformance_input.json | 25 +++++++++++++++++++ test_data/business_store/test_fixtures.json | 20 +++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) create mode 100644 test_data/business_store/conformance_input.json create mode 100644 test_data/business_store/test_fixtures.json diff --git a/checkout_lifecycle_test.py b/checkout_lifecycle_test.py index 2b38375..79134d7 100644 --- a/checkout_lifecycle_test.py +++ b/checkout_lifecycle_test.py @@ -50,6 +50,11 @@ class CheckoutLifecycleTest(integration_test_utils.IntegrationTestBase): - POST /checkout-sessions/{id}/cancel """ + DEFAULT_BUYER = { + "email": "conformance-test-buyer@example.com", + "name": "Conformance Test Buyer", + } + def test_create_checkout(self): """Test successful checkout creation. @@ -232,7 +237,7 @@ def test_complete_checkout(self): Then the response should be 200 OK, the status should be 'completed', and an order ID should be generated. """ - response_json = self.create_checkout_session() + response_json = self.create_checkout_session(buyer=self.DEFAULT_BUYER) checkout_obj = checkout.Checkout(**response_json) checkout_id = checkout_obj.id @@ -378,7 +383,7 @@ def test_cannot_complete_canceled_checkout(self): When a complete request is sent, Then the server should reject it with a non-200 status. """ - response_json = self.create_checkout_session() + response_json = self.create_checkout_session(buyer=self.DEFAULT_BUYER) checkout_id = checkout.Checkout(**response_json).id self._cancel_checkout(checkout_id) @@ -413,7 +418,7 @@ def test_complete_is_idempotent(self): # check BEFORE the status check. If we use a different key (which # default get_headers does), it should fail. """ - response_json = self.create_checkout_session() + response_json = self.create_checkout_session(buyer=self.DEFAULT_BUYER) checkout_id = checkout.Checkout(**response_json).id self._complete_checkout(checkout_id) @@ -437,7 +442,7 @@ def test_cannot_update_completed_checkout(self): When an update request is sent, Then the server should reject it with a non-200 status. """ - response_json = self.create_checkout_session() + response_json = self.create_checkout_session(buyer=self.DEFAULT_BUYER) checkout_obj = checkout.Checkout(**response_json) checkout_id = checkout_obj.id @@ -486,7 +491,7 @@ def test_cannot_cancel_completed_checkout(self): When a cancel request is sent, Then the server should reject it with a non-200 status. """ - response_json = self.create_checkout_session() + response_json = self.create_checkout_session(buyer=self.DEFAULT_BUYER) checkout_id = checkout.Checkout(**response_json).id self._complete_checkout(checkout_id) diff --git a/test_data/business_store/conformance_input.json b/test_data/business_store/conformance_input.json new file mode 100644 index 0000000..e0eeb62 --- /dev/null +++ b/test_data/business_store/conformance_input.json @@ -0,0 +1,25 @@ +{ + "ucp_version": "2026-01-23", + "required_capabilities": [ + "dev.ucp.shopping.checkout", + "dev.ucp.shopping.order", + "dev.ucp.shopping.fulfillment", + "dev.ucp.shopping.discount" + ], + "currency": "USD", + "items": [ + { + "id": "sw005", + "title": "Zurich Classic", + "price": 99000 + } + ], + "out_of_stock_item": { + "id": "sw007", + "title": "Interlaken Gem" + }, + "non_existent_item": { + "id": "non_existent", + "title": "Non-existent Item" + } +} diff --git a/test_data/business_store/test_fixtures.json b/test_data/business_store/test_fixtures.json new file mode 100644 index 0000000..d505b31 --- /dev/null +++ b/test_data/business_store/test_fixtures.json @@ -0,0 +1,20 @@ +{ + "test_fixtures": { + "valid_item": { + "sku": "sw005", + "expected_price": 990.0, + "quantity": 1 + }, + "valid_discount_code": "WELCOME10", + "expected_discount_reduction": 99.0 + }, + "shipping_locations": { + "domestic_destination": { + "street": "123 Market St", + "city": "San Francisco", + "state": "CA", + "postal_code": "94105", + "country": "US" + } + } +} From 4784c8f3a432069520ecc74367d49d143b9e0164 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 14:23:41 +0000 Subject: [PATCH 05/15] test: remove internal test data for business-store.dev Reverts the addition of `test_data/business_store/` configuration files as they were intended for internal testing only. Keeps the generic buyer injection improvements in `checkout_lifecycle_test.py`. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- .../business_store/conformance_input.json | 25 ------------------- test_data/business_store/test_fixtures.json | 20 --------------- 2 files changed, 45 deletions(-) delete mode 100644 test_data/business_store/conformance_input.json delete mode 100644 test_data/business_store/test_fixtures.json diff --git a/test_data/business_store/conformance_input.json b/test_data/business_store/conformance_input.json deleted file mode 100644 index e0eeb62..0000000 --- a/test_data/business_store/conformance_input.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "ucp_version": "2026-01-23", - "required_capabilities": [ - "dev.ucp.shopping.checkout", - "dev.ucp.shopping.order", - "dev.ucp.shopping.fulfillment", - "dev.ucp.shopping.discount" - ], - "currency": "USD", - "items": [ - { - "id": "sw005", - "title": "Zurich Classic", - "price": 99000 - } - ], - "out_of_stock_item": { - "id": "sw007", - "title": "Interlaken Gem" - }, - "non_existent_item": { - "id": "non_existent", - "title": "Non-existent Item" - } -} diff --git a/test_data/business_store/test_fixtures.json b/test_data/business_store/test_fixtures.json deleted file mode 100644 index d505b31..0000000 --- a/test_data/business_store/test_fixtures.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "test_fixtures": { - "valid_item": { - "sku": "sw005", - "expected_price": 990.0, - "quantity": 1 - }, - "valid_discount_code": "WELCOME10", - "expected_discount_reduction": 99.0 - }, - "shipping_locations": { - "domestic_destination": { - "street": "123 Market St", - "city": "San Francisco", - "state": "CA", - "postal_code": "94105", - "country": "US" - } - } -} From 4d8a37d960b813f9a40b625938896cdfacdc0c3c Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 14:29:54 +0000 Subject: [PATCH 06/15] test: use unique buyer email in update checkout test to avoid interference Avoid using jane.doe@example.com in test_update_checkout, as it is used by other tests expecting no stored address, and the server persists addresses updated during checkout. TAG=agy CONV=23802bfc-a499-4064-b976-79aca6e847ef --- checkout_lifecycle_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/checkout_lifecycle_test.py b/checkout_lifecycle_test.py index 79134d7..c58ca22 100644 --- a/checkout_lifecycle_test.py +++ b/checkout_lifecycle_test.py @@ -120,7 +120,7 @@ def test_update_checkout(self): ) buyer_update = buyer_update_request.BuyerUpdateRequest( - email="jane.doe@example.com", + email="update-checkout-buyer@example.com", first_name="Jane", last_name="Doe", phone_number="+15555555556", @@ -185,7 +185,7 @@ def test_update_checkout(self): # Verify updates in the response resp_json = response.json() self.assertEqual( - resp_json.get("buyer", {}).get("email"), "jane.doe@example.com" + resp_json.get("buyer", {}).get("email"), "update-checkout-buyer@example.com" ) self.assertEqual(resp_json.get("buyer", {}).get("first_name"), "Jane") self.assertEqual(resp_json.get("buyer", {}).get("last_name"), "Doe") From 5f3d7976b85a2de910195fdc7595c1fd1a045400 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 14:36:03 +0000 Subject: [PATCH 07/15] test: correct conformance tests to admit fees/taxes and use dynamic discount codes - Updates `assert_totals_consistent` helper to calculate expected totals dynamically, accounting for fulfillment, tax, and fee entries returned by the server (rather than assuming total == subtotal). - Handles negative discount total values according to UCP schema. - Replaces hardcoded discount codes in `business_logic_test.py` with dynamic lookup from the fixture context. - Skips tests requiring fixed discounts or multiple discounts if not configured in the test fixtures. - Updates flower shop `test_fixtures.json` to configure the correct discount codes and expected reductions. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- business_logic_test.py | 229 ++++++++++------------- integration_test_utils.py | 61 ++++++ test_data/flower_shop/test_fixtures.json | 8 +- 3 files changed, 162 insertions(+), 136 deletions(-) diff --git a/business_logic_test.py b/business_logic_test.py index 3b892e5..c823c47 100644 --- a/business_logic_test.py +++ b/business_logic_test.py @@ -43,6 +43,46 @@ class BusinessLogicTest(integration_test_utils.IntegrationTestBase): - GET /checkout-sessions/{id} """ + def assert_totals_consistent( + self, checkout_obj, expected_subtotal, expected_discount=0 + ): + subtotal = next( + (t.amount for t in checkout_obj.totals if t.type == "subtotal"), 0 + ) + fulfillment = next( + (t.amount for t in checkout_obj.totals if t.type == "fulfillment"), 0 + ) + tax = next((t.amount for t in checkout_obj.totals if t.type == "tax"), 0) + fee = next((t.amount for t in checkout_obj.totals if t.type == "fee"), 0) + discount = sum( + t.amount + for t in checkout_obj.totals + if t.type in ["items_discount", "discount"] + ) + total = next((t.amount for t in checkout_obj.totals if t.type == "total"), 0) + + self.assertEqual( + subtotal, + expected_subtotal, + f"Subtotal mismatch: expected {expected_subtotal}, got {subtotal}", + ) + if expected_discount > 0: + self.assertEqual( + abs(discount), + expected_discount, + f"Discount mismatch: expected {expected_discount}, got {discount}", + ) + calculated_total = subtotal + fulfillment + tax + fee - abs(discount) + self.assertEqual( + total, + calculated_total, + ( + f"Total math mismatch: calculated {calculated_total} (subtotal=" + f"{subtotal}, fulfillment={fulfillment}, tax={tax}, fee={fee}, " + f"discount={discount}), got {total}" + ), + ) + def test_totals_calculation_on_create(self): """Test that totals are calculated correctly upon checkout creation. @@ -52,13 +92,7 @@ def test_totals_calculation_on_create(self): 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)) + expected_price = self.fixture_ctx.get_test_price() # Create checkout (client cannot send title/price per schema). The server # should use the authoritative price from its DB (which matches our config). @@ -86,26 +120,7 @@ def test_totals_calculation_on_create(self): ) # 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}", - ) + self.assert_totals_consistent(checkout_obj, expected_price) def test_totals_recalculation_on_update(self): """Test that totals are recalculated correctly upon checkout update. @@ -119,15 +134,9 @@ def test_totals_recalculation_on_update(self): 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) + expected_price = self.fixture_ctx.get_test_price() - # Update quantity to 2. Total should be 2 * expected_price. + # Update quantity to 2. item_update = item_update_request.ItemUpdateRequest( id=checkout_obj.line_items[0].item.id, ) @@ -157,39 +166,25 @@ def test_totals_recalculation_on_update(self): 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}" - ), - ) + self.assert_totals_consistent(updated_checkout, expected_total) 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 + When a valid discount code is applied, + Then the total amount should be reduced, and the applied discount details should be present. """ + valid_code = self.fixture_ctx.get_test_discount_code() + expected_price = self.fixture_ctx.get_test_price() + expected_discount = self.fixture_ctx.get_expected_discount_reduction() + 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, @@ -213,7 +208,7 @@ def test_discount_flow(self): update_dict = update_payload.model_dump( mode="json", by_alias=True, exclude_none=True ) - update_dict["discounts"] = {"codes": ["10OFF"]} + update_dict["discounts"] = {"codes": [valid_code]} response = self.client.put( self.get_shopping_url(f"/checkout-sessions/{checkout_id}"), @@ -223,19 +218,9 @@ def test_discount_flow(self): 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}" - ), + self.assert_totals_consistent( + discounted_checkout, expected_price, expected_discount=expected_discount ) # Parse discounts from extra fields @@ -250,7 +235,7 @@ def test_discount_flow(self): ) self.assertEqual( discounts_obj.applied[0].code, - "10OFF", + valid_code, "Applied discounts field incorrect", ) @@ -258,39 +243,33 @@ 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, + When two valid discount codes 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) + valid_code_1 = self.fixture_ctx.get_test_discount_code() + valid_code_2 = self.fixture_ctx.get_test_discount_code_2() + if not valid_code_2: + self.skipTest("Second valid discount code not configured in fixtures.") - # Get expected price from config - expected_price = ( - self.conformance_config.get("items", [{}])[0].get("price", 3500) - if self.conformance_config - else 3500 + expected_price = self.fixture_ctx.get_test_price() + expected_discount = ( + self.fixture_ctx.get_expected_discount_reduction() + + self.fixture_ctx.get_expected_discount_reduction_2() ) - expected_price = int(expected_price) - # Apply both discounts using helper to ensure all required fields are - # present + response_json = self.create_checkout_session(select_fulfillment=False) + checkout_obj = checkout.Checkout(**response_json) + + # Apply both discounts using helper response_json = self.update_checkout_session( - checkout_obj, discounts={"codes": ["10OFF", "WELCOME20"]} + checkout_obj, discounts={"codes": [valid_code_1, valid_code_2]} ) 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}", + self.assert_totals_consistent( + discounted_checkout, expected_price, expected_discount=expected_discount ) # Verify both applied discounts are present @@ -300,41 +279,34 @@ def test_multiple_discounts_accepted(self): ) 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) + self.assertIn(valid_code_1, applied_codes) + self.assertIn(valid_code_2, 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, + When one valid and one invalid are applied, Then only the valid discount should be applied, and the invalid one should be omitted from the applied list. """ + valid_code = self.fixture_ctx.get_test_discount_code() + expected_price = self.fixture_ctx.get_test_price() + expected_discount = self.fixture_ctx.get_expected_discount_reduction() + 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"]} + checkout_obj, discounts={"codes": [valid_code, "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.assert_totals_consistent( + discounted_checkout, expected_price, expected_discount=expected_discount ) - self.assertEqual(total_obj.amount, expected_total) # Verify only one applied discount is present discounts_data = getattr(discounted_checkout, "discounts", {}) @@ -342,46 +314,35 @@ def test_multiple_discounts_one_rejected(self): 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") + self.assertEqual(discounts_obj.applied[0].code, valid_code) 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 + When a valid fixed-amount discount code is applied, + Then the total amount should be reduced, and the applied discount details should be present. """ + fixed_code = self.fixture_ctx.get_test_fixed_discount_code() + if not fixed_code: + self.skipTest("Fixed discount code not configured in fixtures.") + + expected_price = self.fixture_ctx.get_test_price() + expected_discount = self.fixture_ctx.get_expected_fixed_discount_reduction() + 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"]} + checkout_obj, discounts={"codes": [fixed_code]} ) 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}" - ), + self.assert_totals_consistent( + discounted_checkout, expected_price, expected_discount=expected_discount ) # Parse discounts from extra fields @@ -396,7 +357,7 @@ def test_fixed_amount_discount(self): ) self.assertEqual( discounts_obj.applied[0].code, - "FIXED500", + fixed_code, ) self.assertEqual( discounts_obj.applied[0].amount, diff --git a/integration_test_utils.py b/integration_test_utils.py index 66e6614..5d1bab4 100644 --- a/integration_test_utils.py +++ b/integration_test_utils.py @@ -490,6 +490,67 @@ def get_test_discount_code(self) -> str: return str(val) return "SPRING20" + def get_test_discount_code_2(self) -> str | None: + """Get a second valid discount code for tests.""" + val = self._config.get("test_discount_code_2") + if val is None: + val = self._fallback_config.get("test_discount_code_2") + if val is not None: + return str(val) + + val = self._config.get("test_fixtures", {}).get("valid_discount_code_2") + if val is None: + val = self._fallback_config.get("test_fixtures", {}).get( + "valid_discount_code_2" + ) + if val is not None: + return str(val) + return None + + def get_test_fixed_discount_code(self) -> str | None: + """Get a valid fixed discount code for tests.""" + val = self._config.get("test_fixed_discount_code") + if val is None: + val = self._fallback_config.get("test_fixed_discount_code") + if val is not None: + return str(val) + + val = self._config.get("test_fixtures", {}).get("valid_fixed_discount_code") + if val is None: + val = self._fallback_config.get("test_fixtures", {}).get( + "valid_fixed_discount_code" + ) + if val is not None: + return str(val) + return None + + def get_expected_discount_reduction(self, default=0.0) -> int: + """Get expected discount reduction in minor units.""" + val = self._config.get("test_fixtures", {}).get("expected_discount_reduction") + if val is None: + val = self._fallback_config.get("test_fixtures", {}).get("expected_discount_reduction") + if val is not None: + return int(round(float(val) * 100)) + return int(round(default * 100)) + + def get_expected_discount_reduction_2(self, default=0.0) -> int: + """Get expected discount reduction 2 in minor units.""" + val = self._config.get("test_fixtures", {}).get("expected_discount_reduction_2") + if val is None: + val = self._fallback_config.get("test_fixtures", {}).get("expected_discount_reduction_2") + if val is not None: + return int(round(float(val) * 100)) + return int(round(default * 100)) + + def get_expected_fixed_discount_reduction(self, default=0.0) -> int: + """Get expected fixed discount reduction in minor units.""" + val = self._config.get("test_fixtures", {}).get("expected_fixed_discount_reduction") + if val is None: + val = self._fallback_config.get("test_fixtures", {}).get("expected_fixed_discount_reduction") + if val is not None: + return int(round(float(val) * 100)) + return int(round(default * 100)) + ConfiguredFixtureContext = DynamicFixtureContext diff --git a/test_data/flower_shop/test_fixtures.json b/test_data/flower_shop/test_fixtures.json index 46cd31f..6c4e6f4 100644 --- a/test_data/flower_shop/test_fixtures.json +++ b/test_data/flower_shop/test_fixtures.json @@ -5,8 +5,12 @@ "expected_price": 35.0, "quantity": 1 }, - "valid_discount_code": "SPRING20", - "expected_discount_reduction": 5.0 + "valid_discount_code": "10OFF", + "expected_discount_reduction": 3.5, + "valid_discount_code_2": "WELCOME20", + "expected_discount_reduction_2": 6.3, + "valid_fixed_discount_code": "FIXED500", + "expected_fixed_discount_reduction": 5.0 }, "shipping_locations": { "domestic_destination": { From ae7346b59674d886eb88d2c375b7d5a9a3ca318b Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 14:42:56 +0000 Subject: [PATCH 08/15] docs: document optional discount fixture config in README Updates README.md to explain that `valid_discount_code_2` and `valid_fixed_discount_code` are optional in `test_fixtures.json` and that their respective tests will be skipped if they are not provided. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 132d73a..cb243c7 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,11 @@ Example `test_fixtures.json`: "quantity": 1 }, "valid_discount_code": "PROMO10", - "expected_discount_reduction": 1.0 + "expected_discount_reduction": 1.0, + "valid_discount_code_2": "PROMO20", + "expected_discount_reduction_2": 1.8, + "valid_fixed_discount_code": "FIXED5", + "expected_fixed_discount_reduction": 5.0 }, "shipping_locations": { "domestic_destination": { @@ -96,7 +100,10 @@ Example `test_fixtures.json`: ``` - `valid_item`: The SKU and expected price (in major units, e.g., `10.00`) of the item used in tests. -- `valid_discount_code` & `expected_discount_reduction`: A discount code that should apply successfully and the expected reduction amount (in major units). +- **Discounts Configuration**: + - `valid_discount_code` & `expected_discount_reduction`: **Required** for basic discount flow tests. The code must be valid on your server, and the reduction is the expected amount in major units. + - `valid_discount_code_2` & `expected_discount_reduction_2`: **Optional**. Used for testing multiple discount codes applied sequentially. If your server does not support multiple discounts or you don't configure this, the corresponding test will be skipped. + - `valid_fixed_discount_code` & `expected_fixed_discount_reduction`: **Optional**. Used for testing fixed-amount discounts. If your server only supports percentage discounts or you don't configure this, the test will be skipped. - `shipping_locations`: Addresses used for fulfillment tests. ### 4. Run the Tests From 791fee4c64ad5428b6aea455b50124b39bcaf38e Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 15:02:49 +0000 Subject: [PATCH 09/15] test: support both sequential and cumulative discount calculations in conformance - Updates `test_fixtures.json` to define discount percentages instead of absolute expected reductions. - Updates conformance test helper to calculate expected percentage discount dynamically. - Updates `test_multiple_discounts_accepted` to calculate both sequential and cumulative discount results, accepting either from the server. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- business_logic_test.py | 44 +++++++++++++++++------- integration_test_utils.py | 24 ++++++------- test_data/flower_shop/test_fixtures.json | 4 +-- 3 files changed, 46 insertions(+), 26 deletions(-) diff --git a/business_logic_test.py b/business_logic_test.py index c823c47..17f4eb0 100644 --- a/business_logic_test.py +++ b/business_logic_test.py @@ -66,12 +66,19 @@ def assert_totals_consistent( expected_subtotal, f"Subtotal mismatch: expected {expected_subtotal}, got {subtotal}", ) - if expected_discount > 0: - self.assertEqual( - abs(discount), - expected_discount, - f"Discount mismatch: expected {expected_discount}, got {discount}", - ) + if expected_discount: + if isinstance(expected_discount, (list, set, tuple)): + self.assertIn( + abs(discount), + expected_discount, + f"Discount mismatch: expected one of {expected_discount}, got {discount}", + ) + else: + self.assertEqual( + abs(discount), + expected_discount, + f"Discount mismatch: expected {expected_discount}, got {discount}", + ) calculated_total = subtotal + fulfillment + tax + fee - abs(discount) self.assertEqual( total, @@ -179,7 +186,8 @@ def test_discount_flow(self): """ valid_code = self.fixture_ctx.get_test_discount_code() expected_price = self.fixture_ctx.get_test_price() - expected_discount = self.fixture_ctx.get_expected_discount_reduction() + percentage = self.fixture_ctx.get_expected_discount_percentage() + expected_discount = int(expected_price * (percentage / 100)) response_json = self.create_checkout_session(select_fulfillment=False) checkout_obj = checkout.Checkout(**response_json) @@ -253,10 +261,21 @@ def test_multiple_discounts_accepted(self): self.skipTest("Second valid discount code not configured in fixtures.") expected_price = self.fixture_ctx.get_test_price() - expected_discount = ( - self.fixture_ctx.get_expected_discount_reduction() - + self.fixture_ctx.get_expected_discount_reduction_2() - ) + percentage1 = self.fixture_ctx.get_expected_discount_percentage() + percentage2 = self.fixture_ctx.get_expected_discount_percentage_2() + + # Cumulative discount: each calculated on the original price + d1_cum = int(expected_price * (percentage1 / 100)) + d2_cum = int(expected_price * (percentage2 / 100)) + expected_discount_cumulative = d1_cum + d2_cum + + # Sequential discount: second calculated on the remaining balance + d1_seq = int(expected_price * (percentage1 / 100)) + d2_seq = int((expected_price - d1_seq) * (percentage2 / 100)) + expected_discount_sequential = d1_seq + d2_seq + + # Accept either approach + expected_discount = [expected_discount_sequential, expected_discount_cumulative] response_json = self.create_checkout_session(select_fulfillment=False) checkout_obj = checkout.Checkout(**response_json) @@ -292,7 +311,8 @@ def test_multiple_discounts_one_rejected(self): """ valid_code = self.fixture_ctx.get_test_discount_code() expected_price = self.fixture_ctx.get_test_price() - expected_discount = self.fixture_ctx.get_expected_discount_reduction() + percentage = self.fixture_ctx.get_expected_discount_percentage() + expected_discount = int(expected_price * (percentage / 100)) response_json = self.create_checkout_session(select_fulfillment=False) checkout_obj = checkout.Checkout(**response_json) diff --git a/integration_test_utils.py b/integration_test_utils.py index 5d1bab4..1778a62 100644 --- a/integration_test_utils.py +++ b/integration_test_utils.py @@ -524,23 +524,23 @@ def get_test_fixed_discount_code(self) -> str | None: return str(val) return None - def get_expected_discount_reduction(self, default=0.0) -> int: - """Get expected discount reduction in minor units.""" - val = self._config.get("test_fixtures", {}).get("expected_discount_reduction") + def get_expected_discount_percentage(self, default=0.0) -> float: + """Get expected discount percentage (e.g. 10.0 for 10%).""" + val = self._config.get("test_fixtures", {}).get("expected_discount_percentage") if val is None: - val = self._fallback_config.get("test_fixtures", {}).get("expected_discount_reduction") + val = self._fallback_config.get("test_fixtures", {}).get("expected_discount_percentage") if val is not None: - return int(round(float(val) * 100)) - return int(round(default * 100)) + return float(val) + return float(default) - def get_expected_discount_reduction_2(self, default=0.0) -> int: - """Get expected discount reduction 2 in minor units.""" - val = self._config.get("test_fixtures", {}).get("expected_discount_reduction_2") + def get_expected_discount_percentage_2(self, default=0.0) -> float: + """Get expected discount percentage 2.""" + val = self._config.get("test_fixtures", {}).get("expected_discount_percentage_2") if val is None: - val = self._fallback_config.get("test_fixtures", {}).get("expected_discount_reduction_2") + val = self._fallback_config.get("test_fixtures", {}).get("expected_discount_percentage_2") if val is not None: - return int(round(float(val) * 100)) - return int(round(default * 100)) + return float(val) + return float(default) def get_expected_fixed_discount_reduction(self, default=0.0) -> int: """Get expected fixed discount reduction in minor units.""" diff --git a/test_data/flower_shop/test_fixtures.json b/test_data/flower_shop/test_fixtures.json index 6c4e6f4..c7f1b95 100644 --- a/test_data/flower_shop/test_fixtures.json +++ b/test_data/flower_shop/test_fixtures.json @@ -6,9 +6,9 @@ "quantity": 1 }, "valid_discount_code": "10OFF", - "expected_discount_reduction": 3.5, + "expected_discount_percentage": 10.0, "valid_discount_code_2": "WELCOME20", - "expected_discount_reduction_2": 6.3, + "expected_discount_percentage_2": 20.0, "valid_fixed_discount_code": "FIXED500", "expected_fixed_discount_reduction": 5.0 }, From 3e6a9cca573fdde57549b84362332f00aba01877 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 15:05:34 +0000 Subject: [PATCH 10/15] style: fix linter errors and reformat files - Fixes E501 (line too long) and formatting issues using ruff format. - Adds missing docstring to `assert_totals_consistent` helper. - Fixes D401 (imperative mood docstring) and wraps long lines in business_logic_test.py. TAG=agy CONV=3ec5230d-ffcc-4f60-ae67-cf9d0f460020 --- business_logic_test.py | 65 ++++++++++++++++++++++---------------- checkout_lifecycle_test.py | 7 ++-- integration_test_utils.py | 24 ++++++++++---- 3 files changed, 59 insertions(+), 37 deletions(-) diff --git a/business_logic_test.py b/business_logic_test.py index 17f4eb0..4d2af9f 100644 --- a/business_logic_test.py +++ b/business_logic_test.py @@ -44,50 +44,56 @@ class BusinessLogicTest(integration_test_utils.IntegrationTestBase): """ def assert_totals_consistent( - self, checkout_obj, expected_subtotal, expected_discount=0 + self, checkout_obj, expected_subtotal, expected_discount=0 ): + """Assert that the checkout totals are consistent and correct.""" subtotal = next( - (t.amount for t in checkout_obj.totals if t.type == "subtotal"), 0 + (t.amount for t in checkout_obj.totals if t.type == "subtotal"), 0 ) fulfillment = next( - (t.amount for t in checkout_obj.totals if t.type == "fulfillment"), 0 + (t.amount for t in checkout_obj.totals if t.type == "fulfillment"), 0 ) tax = next((t.amount for t in checkout_obj.totals if t.type == "tax"), 0) fee = next((t.amount for t in checkout_obj.totals if t.type == "fee"), 0) discount = sum( - t.amount - for t in checkout_obj.totals - if t.type in ["items_discount", "discount"] + t.amount + for t in checkout_obj.totals + if t.type in ["items_discount", "discount"] + ) + total = next( + (t.amount for t in checkout_obj.totals if t.type == "total"), 0 ) - total = next((t.amount for t in checkout_obj.totals if t.type == "total"), 0) self.assertEqual( - subtotal, - expected_subtotal, - f"Subtotal mismatch: expected {expected_subtotal}, got {subtotal}", + subtotal, + expected_subtotal, + f"Subtotal mismatch: expected {expected_subtotal}, got {subtotal}", ) if expected_discount: if isinstance(expected_discount, (list, set, tuple)): self.assertIn( - abs(discount), - expected_discount, - f"Discount mismatch: expected one of {expected_discount}, got {discount}", + abs(discount), + expected_discount, + ( + f"Discount mismatch: expected one of {expected_discount}, got" + f" {discount}" + ), ) else: self.assertEqual( - abs(discount), - expected_discount, - f"Discount mismatch: expected {expected_discount}, got {discount}", + abs(discount), + expected_discount, + f"Discount mismatch: expected {expected_discount}, got {discount}", ) calculated_total = subtotal + fulfillment + tax + fee - abs(discount) self.assertEqual( - total, - calculated_total, - ( - f"Total math mismatch: calculated {calculated_total} (subtotal=" - f"{subtotal}, fulfillment={fulfillment}, tax={tax}, fee={fee}, " - f"discount={discount}), got {total}" - ), + total, + calculated_total, + ( + f"Total math mismatch: calculated {calculated_total} (subtotal=" + f"{subtotal}, fulfillment={fulfillment}, tax={tax}, fee={fee}, " + f"discount={discount}), got {total}" + ), ) def test_totals_calculation_on_create(self): @@ -228,7 +234,7 @@ def test_discount_flow(self): discounted_checkout = checkout.Checkout(**response.json()) self.assert_totals_consistent( - discounted_checkout, expected_price, expected_discount=expected_discount + discounted_checkout, expected_price, expected_discount=expected_discount ) # Parse discounts from extra fields @@ -275,7 +281,10 @@ def test_multiple_discounts_accepted(self): expected_discount_sequential = d1_seq + d2_seq # Accept either approach - expected_discount = [expected_discount_sequential, expected_discount_cumulative] + expected_discount = [ + expected_discount_sequential, + expected_discount_cumulative, + ] response_json = self.create_checkout_session(select_fulfillment=False) checkout_obj = checkout.Checkout(**response_json) @@ -288,7 +297,7 @@ def test_multiple_discounts_accepted(self): discounted_checkout = checkout.Checkout(**response_json) self.assert_totals_consistent( - discounted_checkout, expected_price, expected_discount=expected_discount + discounted_checkout, expected_price, expected_discount=expected_discount ) # Verify both applied discounts are present @@ -325,7 +334,7 @@ def test_multiple_discounts_one_rejected(self): discounted_checkout = checkout.Checkout(**response_json) self.assert_totals_consistent( - discounted_checkout, expected_price, expected_discount=expected_discount + discounted_checkout, expected_price, expected_discount=expected_discount ) # Verify only one applied discount is present @@ -362,7 +371,7 @@ def test_fixed_amount_discount(self): discounted_checkout = checkout.Checkout(**response_json) self.assert_totals_consistent( - discounted_checkout, expected_price, expected_discount=expected_discount + discounted_checkout, expected_price, expected_discount=expected_discount ) # Parse discounts from extra fields diff --git a/checkout_lifecycle_test.py b/checkout_lifecycle_test.py index c58ca22..44bba18 100644 --- a/checkout_lifecycle_test.py +++ b/checkout_lifecycle_test.py @@ -51,8 +51,8 @@ class CheckoutLifecycleTest(integration_test_utils.IntegrationTestBase): """ DEFAULT_BUYER = { - "email": "conformance-test-buyer@example.com", - "name": "Conformance Test Buyer", + "email": "conformance-test-buyer@example.com", + "name": "Conformance Test Buyer", } def test_create_checkout(self): @@ -185,7 +185,8 @@ def test_update_checkout(self): # Verify updates in the response resp_json = response.json() self.assertEqual( - resp_json.get("buyer", {}).get("email"), "update-checkout-buyer@example.com" + resp_json.get("buyer", {}).get("email"), + "update-checkout-buyer@example.com", ) self.assertEqual(resp_json.get("buyer", {}).get("first_name"), "Jane") self.assertEqual(resp_json.get("buyer", {}).get("last_name"), "Doe") diff --git a/integration_test_utils.py b/integration_test_utils.py index 1778a62..a31d4a2 100644 --- a/integration_test_utils.py +++ b/integration_test_utils.py @@ -526,27 +526,39 @@ def get_test_fixed_discount_code(self) -> str | None: def get_expected_discount_percentage(self, default=0.0) -> float: """Get expected discount percentage (e.g. 10.0 for 10%).""" - val = self._config.get("test_fixtures", {}).get("expected_discount_percentage") + val = self._config.get("test_fixtures", {}).get( + "expected_discount_percentage" + ) if val is None: - val = self._fallback_config.get("test_fixtures", {}).get("expected_discount_percentage") + val = self._fallback_config.get("test_fixtures", {}).get( + "expected_discount_percentage" + ) if val is not None: return float(val) return float(default) def get_expected_discount_percentage_2(self, default=0.0) -> float: """Get expected discount percentage 2.""" - val = self._config.get("test_fixtures", {}).get("expected_discount_percentage_2") + val = self._config.get("test_fixtures", {}).get( + "expected_discount_percentage_2" + ) if val is None: - val = self._fallback_config.get("test_fixtures", {}).get("expected_discount_percentage_2") + val = self._fallback_config.get("test_fixtures", {}).get( + "expected_discount_percentage_2" + ) if val is not None: return float(val) return float(default) def get_expected_fixed_discount_reduction(self, default=0.0) -> int: """Get expected fixed discount reduction in minor units.""" - val = self._config.get("test_fixtures", {}).get("expected_fixed_discount_reduction") + val = self._config.get("test_fixtures", {}).get( + "expected_fixed_discount_reduction" + ) if val is None: - val = self._fallback_config.get("test_fixtures", {}).get("expected_fixed_discount_reduction") + val = self._fallback_config.get("test_fixtures", {}).get( + "expected_fixed_discount_reduction" + ) if val is not None: return int(round(float(val) * 100)) return int(round(default * 100)) From 2dfdfe8a9bcf1736b36c9f133f4ed6132a43d206 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 17:16:26 +0000 Subject: [PATCH 11/15] style: fix markdown lint indentation in README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cb243c7..331c8be 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ Example `test_fixtures.json`: - `valid_item`: The SKU and expected price (in major units, e.g., `10.00`) of the item used in tests. - **Discounts Configuration**: - - `valid_discount_code` & `expected_discount_reduction`: **Required** for basic discount flow tests. The code must be valid on your server, and the reduction is the expected amount in major units. - - `valid_discount_code_2` & `expected_discount_reduction_2`: **Optional**. Used for testing multiple discount codes applied sequentially. If your server does not support multiple discounts or you don't configure this, the corresponding test will be skipped. - - `valid_fixed_discount_code` & `expected_fixed_discount_reduction`: **Optional**. Used for testing fixed-amount discounts. If your server only supports percentage discounts or you don't configure this, the test will be skipped. + - `valid_discount_code` & `expected_discount_reduction`: **Required** for basic discount flow tests. The code must be valid on your server, and the reduction is the expected amount in major units. + - `valid_discount_code_2` & `expected_discount_reduction_2`: **Optional**. Used for testing multiple discount codes applied sequentially. If your server does not support multiple discounts or you don't configure this, the corresponding test will be skipped. + - `valid_fixed_discount_code` & `expected_fixed_discount_reduction`: **Optional**. Used for testing fixed-amount discounts. If your server only supports percentage discounts or you don't configure this, the test will be skipped. - `shipping_locations`: Addresses used for fulfillment tests. ### 4. Run the Tests From 6d8586464f0cecb3fc1397d6b7d3c15ca8beca98 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Wed, 22 Jul 2026 17:21:37 +0000 Subject: [PATCH 12/15] style: configure markdownlint to match prettier, resolve conflict --- .markdownlint.json | 5 +++++ README.md | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .markdownlint.json diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..799b78f --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "default": true, + "MD007": { "indent": 2 }, + "MD013": false +} diff --git a/README.md b/README.md index 331c8be..cb243c7 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ Example `test_fixtures.json`: - `valid_item`: The SKU and expected price (in major units, e.g., `10.00`) of the item used in tests. - **Discounts Configuration**: - - `valid_discount_code` & `expected_discount_reduction`: **Required** for basic discount flow tests. The code must be valid on your server, and the reduction is the expected amount in major units. - - `valid_discount_code_2` & `expected_discount_reduction_2`: **Optional**. Used for testing multiple discount codes applied sequentially. If your server does not support multiple discounts or you don't configure this, the corresponding test will be skipped. - - `valid_fixed_discount_code` & `expected_fixed_discount_reduction`: **Optional**. Used for testing fixed-amount discounts. If your server only supports percentage discounts or you don't configure this, the test will be skipped. + - `valid_discount_code` & `expected_discount_reduction`: **Required** for basic discount flow tests. The code must be valid on your server, and the reduction is the expected amount in major units. + - `valid_discount_code_2` & `expected_discount_reduction_2`: **Optional**. Used for testing multiple discount codes applied sequentially. If your server does not support multiple discounts or you don't configure this, the corresponding test will be skipped. + - `valid_fixed_discount_code` & `expected_fixed_discount_reduction`: **Optional**. Used for testing fixed-amount discounts. If your server only supports percentage discounts or you don't configure this, the test will be skipped. - `shipping_locations`: Addresses used for fulfillment tests. ### 4. Run the Tests From c81d5afe72c9259b4a0453230aeff27caf6fdffa Mon Sep 17 00:00:00 2001 From: damaz91 Date: Thu, 23 Jul 2026 11:10:06 +0000 Subject: [PATCH 13/15] fix: update conformance tests for webhook order body and headers TAG=agy CONV=f0d818cd-0531-40ee-880f-865ddf2b2a5f --- integration_test_utils.py | 9 ++++++++- webhook_test.py | 34 +++++++++++++++++----------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/integration_test_utils.py b/integration_test_utils.py index a31d4a2..461cd0d 100644 --- a/integration_test_utils.py +++ b/integration_test_utils.py @@ -319,7 +319,14 @@ def _setup_routes(self) -> None: 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}) + headers = dict(request.headers) + self.events.append( + { + "partner_id": partner_id, + "payload": payload, + "headers": headers, + } + ) return {"status": "ok"} @self.app.get("/healthz") diff --git a/webhook_test.py b/webhook_test.py index a72b0d7..005c39c 100644 --- a/webhook_test.py +++ b/webhook_test.py @@ -90,23 +90,31 @@ def test_webhook_event_stream(self) -> None: # Verify order_placed event placed_event = next( - (e for e in events if e["payload"]["event_type"] == "order_placed"), + ( + e + for e in events + if e.get("headers", {}).get("x-event-type") == "order_placed" + ), None, ) self.assertIsNotNone(placed_event, "Missing order_placed event") self.assertEqual(placed_event["payload"]["checkout_id"], checkout_id) - self.assertEqual(placed_event["payload"]["order"]["id"], order_id) + self.assertEqual(placed_event["payload"]["id"], order_id) # Verify order_shipped event shipped_event = next( - (e for e in events if e["payload"]["event_type"] == "order_shipped"), + ( + e + for e in events + if e.get("headers", {}).get("x-event-type") == "order_shipped" + ), None, ) self.assertIsNotNone(shipped_event, "Missing order_shipped event") self.assertEqual(shipped_event["payload"]["checkout_id"], checkout_id) - self.assertEqual(shipped_event["payload"]["order"]["id"], order_id) + self.assertEqual(shipped_event["payload"]["id"], order_id) - fulfillment_events = shipped_event["payload"]["order"]["fulfillment"].get( + fulfillment_events = shipped_event["payload"]["fulfillment"].get( "events", [] ) self.assertTrue( @@ -204,15 +212,11 @@ def test_webhook_order_address_known_customer(self) -> None: time.sleep(0.1) event = next( - ( - e - for e in self.webhook_server.events - if e["payload"]["order"]["id"] == order_id - ), + (e for e in self.webhook_server.events if e["payload"]["id"] == order_id), None, ) self.assertIsNotNone(event) - expectations = event["payload"]["order"]["fulfillment"]["expectations"] + expectations = event["payload"]["fulfillment"]["expectations"] self.assertTrue(expectations) self.assertEqual(expectations[0]["destination"]["address_country"], "US") @@ -278,15 +282,11 @@ def test_webhook_order_address_new_address(self) -> None: time.sleep(0.1) event = next( - ( - e - for e in self.webhook_server.events - if e["payload"]["order"]["id"] == order_id - ), + (e for e in self.webhook_server.events if e["payload"]["id"] == order_id), None, ) self.assertIsNotNone(event) - expectations = event["payload"]["order"]["fulfillment"]["expectations"] + expectations = event["payload"]["fulfillment"]["expectations"] self.assertTrue(expectations) self.assertEqual(expectations[0]["destination"]["address_country"], "CA") self.assertEqual( From e6f1eb2569408c2098b1f5ad7beef710204beda8 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Thu, 23 Jul 2026 11:16:23 +0000 Subject: [PATCH 14/15] fix: use 2-space indent in .github/linters/.markdownlint.json Aligns Markdownlint with Prettier to resolve CI conflict. Removes duplicate root .markdownlint.json. TAG=agy CONV=f0d818cd-0531-40ee-880f-865ddf2b2a5f --- .github/linters/.markdownlint.json | 2 +- .markdownlint.json | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .markdownlint.json diff --git a/.github/linters/.markdownlint.json b/.github/linters/.markdownlint.json index 38fb124..9822820 100644 --- a/.github/linters/.markdownlint.json +++ b/.github/linters/.markdownlint.json @@ -2,7 +2,7 @@ "default": true, "MD013": false, "MD007": { - "indent": 4 + "indent": 2 }, "MD033": false, "MD046": false, diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index 799b78f..0000000 --- a/.markdownlint.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "default": true, - "MD007": { "indent": 2 }, - "MD013": false -} From d6b2fb3ef129fe07db262733bdb708054076b50a Mon Sep 17 00:00:00 2001 From: damaz91 Date: Thu, 23 Jul 2026 13:31:46 +0000 Subject: [PATCH 15/15] test: use round() instead of int() for discount calculations Convert discount calculations from truncation (int()) to rounding (round()) to match standard business logic and avoid test failures when fixtures produce fractional cents. TAG=agy CONV=9f52b360-143c-4223-90ca-9a0e665c7c08 --- business_logic_test.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/business_logic_test.py b/business_logic_test.py index 4d2af9f..2719cf0 100644 --- a/business_logic_test.py +++ b/business_logic_test.py @@ -193,7 +193,7 @@ def test_discount_flow(self): valid_code = self.fixture_ctx.get_test_discount_code() expected_price = self.fixture_ctx.get_test_price() percentage = self.fixture_ctx.get_expected_discount_percentage() - expected_discount = int(expected_price * (percentage / 100)) + expected_discount = round(expected_price * (percentage / 100)) response_json = self.create_checkout_session(select_fulfillment=False) checkout_obj = checkout.Checkout(**response_json) @@ -271,13 +271,13 @@ def test_multiple_discounts_accepted(self): percentage2 = self.fixture_ctx.get_expected_discount_percentage_2() # Cumulative discount: each calculated on the original price - d1_cum = int(expected_price * (percentage1 / 100)) - d2_cum = int(expected_price * (percentage2 / 100)) + d1_cum = round(expected_price * (percentage1 / 100)) + d2_cum = round(expected_price * (percentage2 / 100)) expected_discount_cumulative = d1_cum + d2_cum # Sequential discount: second calculated on the remaining balance - d1_seq = int(expected_price * (percentage1 / 100)) - d2_seq = int((expected_price - d1_seq) * (percentage2 / 100)) + d1_seq = round(expected_price * (percentage1 / 100)) + d2_seq = round((expected_price - d1_seq) * (percentage2 / 100)) expected_discount_sequential = d1_seq + d2_seq # Accept either approach @@ -321,7 +321,7 @@ def test_multiple_discounts_one_rejected(self): valid_code = self.fixture_ctx.get_test_discount_code() expected_price = self.fixture_ctx.get_test_price() percentage = self.fixture_ctx.get_expected_discount_percentage() - expected_discount = int(expected_price * (percentage / 100)) + expected_discount = round(expected_price * (percentage / 100)) response_json = self.create_checkout_session(select_fulfillment=False) checkout_obj = checkout.Checkout(**response_json)