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/README.md b/README.md index a24947c..cb243c7 100644 --- a/README.md +++ b/README.md @@ -14,39 +14,150 @@ 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, + "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": { + "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. +- **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. -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 and the sample server: + +```bash +uv sync +uv sync --directory ../samples/rest/python/server/ +``` + +### 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 +165,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 +181,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 ``` diff --git a/business_logic_test.py b/business_logic_test.py index 3b892e5..2719cf0 100644 --- a/business_logic_test.py +++ b/business_logic_test.py @@ -43,6 +43,59 @@ class BusinessLogicTest(integration_test_utils.IntegrationTestBase): - GET /checkout-sessions/{id} """ + def assert_totals_consistent( + 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 + ) + 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: + if isinstance(expected_discount, (list, set, tuple)): + self.assertIn( + 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}", + ) + 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 +105,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 +133,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 +147,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 +179,26 @@ 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() + percentage = self.fixture_ctx.get_expected_discount_percentage() + expected_discount = round(expected_price * (percentage / 100)) + 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 +222,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 +232,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 +249,7 @@ def test_discount_flow(self): ) self.assertEqual( discounts_obj.applied[0].code, - "10OFF", + valid_code, "Applied discounts field incorrect", ) @@ -258,39 +257,47 @@ 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. """ + 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.") + + expected_price = self.fixture_ctx.get_test_price() + 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 = 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 = round(expected_price * (percentage1 / 100)) + d2_seq = round((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) - # 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 + # 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 +307,35 @@ 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() + percentage = self.fixture_ctx.get_expected_discount_percentage() + expected_discount = round(expected_price * (percentage / 100)) + 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 +343,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 +386,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/checkout_lifecycle_test.py b/checkout_lifecycle_test.py index dbb1283..44bba18 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}) @@ -42,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. @@ -81,7 +94,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 +119,57 @@ def test_update_checkout(self): ], ) + buyer_update = buyer_update_request.BuyerUpdateRequest( + email="update-checkout-buyer@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 +182,30 @@ 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"), + "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") + + 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. @@ -155,7 +238,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 @@ -301,7 +384,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) @@ -336,7 +419,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) @@ -360,7 +443,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 @@ -409,7 +492,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/integration_test_utils.py b/integration_test_utils.py index 66e6614..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") @@ -490,6 +497,79 @@ 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_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_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" + ) + if val is None: + 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" + ) + 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..c7f1b95 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_percentage": 10.0, + "valid_discount_code_2": "WELCOME20", + "expected_discount_percentage_2": 20.0, + "valid_fixed_discount_code": "FIXED500", + "expected_fixed_discount_reduction": 5.0 }, "shipping_locations": { "domestic_destination": { 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(