From fa5c64964483dfa40925d158f2d1ccc62a9738f0 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Tue, 21 Jul 2026 19:16:32 -0400 Subject: [PATCH 1/3] test: add discount capability conformance module Complements business_logic_test.py by covering discount MUSTs the suite does not assert: case-insensitive code matching (DSC-005), the negative sign of a discount's totals[] entry (DSC-021; total.json exclusiveMaximum: 0), codes[] replacement (DSC-003) and removal (DSC-004), the cross-field invariant that an applied discount's allocations[] sum to its amount (DSC-022), and that client-supplied discounts.applied[] (response-only, ucp_request: omit) does not change the priced total (DSC-027). Uses the fixture_ctx discount-code helpers (get_test_discount_code / get_test_discount_code_2) and the valid_discount_code / valid_discount_code_2 conformance_input keys, consistent with business_logic_test after #63. Skips honestly when the business does not advertise the capability or emits no allocations[]. --- discount_test.py | 250 +++++++++++++++++++ test_data/flower_shop/conformance_input.json | 4 + 2 files changed, 254 insertions(+) create mode 100644 discount_test.py diff --git a/discount_test.py b/discount_test.py new file mode 100644 index 0000000..f255e3e --- /dev/null +++ b/discount_test.py @@ -0,0 +1,250 @@ +# Copyright 2026 UCP Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Conformance tests for the discount capability (discount.md). + +Complements business_logic_test.py (discount flow, multiple-code accept/reject, +fixed-amount) by covering discount MUSTs the existing suite does not assert: + + DSC-003 a new codes[] set replaces the previous one + DSC-004 an empty codes[] removes all discounts + DSC-005 codes are matched case-insensitively + DSC-021 a discount's totals[] entry is negative (total.json exclusiveMax 0) + DSC-022 an applied discount's allocations[] sum to its amount + DSC-027 client-supplied applied[] (response-only) must not change pricing + +Server-agnostic: assertions are relative (a discount reduced the total; the +applied code is echoed; the discount total is negative; an empty code set +removes it; allocations reconcile) rather than tied to a specific discount +value, so any conformant business passes regardless of the discount amount. +Configure the codes via conformance_input.json under test_fixtures +(valid_discount_code / valid_discount_code_2); the tests skip honestly when no +code is supplied or when the business does not advertise the capability. +""" + +from absl.testing import absltest +import integration_test_utils +from ucp_sdk.models.schemas.shopping import checkout as checkout + +_DISCOUNT_CAPABILITY = "dev.ucp.shopping.discount" + + +class DiscountTest(integration_test_utils.IntegrationTestBase): + """Discount capability conformance (discount.md).""" + + def setUp(self) -> None: + """Skip unless the business advertises discount + a code is configured.""" + super().setUp() + if not self._advertises_discount(): + self.skipTest( + f"business does not advertise {_DISCOUNT_CAPABILITY}; skipping" + ) + self._code = self.fixture_ctx.get_test_discount_code() + + def _advertises_discount(self) -> bool: + """Return True if discovery advertises the discount capability.""" + resp = self.client.get("/.well-known/ucp") + self.assert_response_status(resp, 200) + ucp = resp.json().get("ucp", resp.json()) + caps = ucp.get("capabilities") or {} + names = ( + list(caps.keys()) + if isinstance(caps, dict) + else [c.get("name") for c in caps if isinstance(c, dict)] + ) + return _DISCOUNT_CAPABILITY in names + + # ── shared drivers (operate on the raw response dict, as discount.md + # describes the wire shape and the sample suite asserts) ────────────── + def _new_checkout(self): + """Create a checkout and return (checkout_model, raw_dict).""" + raw = self.create_checkout_session(select_fulfillment=False) + return checkout.Checkout(**raw), raw + + def _apply_codes(self, checkout_obj, codes): + """Apply a discount code set; return the raw updated-checkout dict.""" + return self.update_checkout_session( + checkout_obj, + discounts={"codes": codes}, + headers=integration_test_utils.get_headers(), + ) + + @staticmethod + def _discount_total(raw): + return next( + (t for t in (raw.get("totals") or []) if t.get("type") == "discount"), + None, + ) + + @staticmethod + def _total_amount(raw): + t = next( + (t for t in (raw.get("totals") or []) if t.get("type") == "total"), None + ) + return t.get("amount") if t else None + + @staticmethod + def _applied_codes(raw): + applied = (raw.get("discounts") or {}).get("applied") or [] + return [ + a.get("code") for a in applied if isinstance(a, dict) and a.get("code") + ] + + # ── DSC-005: case-insensitive matching (discount.md) ──────────────────── + def test_code_matches_case_insensitively(self): + """A code submitted in a different case still matches and applies.""" + base, _ = self._new_checkout() + swapped = self._code.swapcase() + self.assertNotEqual( + swapped, self._code, "test code has no letters to change case on" + ) + raw = self._apply_codes(base, [swapped]) + applied = [c.upper() for c in self._applied_codes(raw)] + self.assertIn( + self._code.upper(), + applied, + f"case-variant '{swapped}' of '{self._code}' should match and appear in " + f"discounts.applied (got {applied})", + ) + self.assertIsNotNone( + self._discount_total(raw), + "a matched discount must produce a discount total", + ) + + # ── discount total is negative (discount.md / total.json) ─────────────── + def test_discount_total_is_negative(self): + """A discount's totals[] entry is negative (total.json exclusiveMax 0).""" + base, base_raw = self._new_checkout() + total_before = self._total_amount(base_raw) + raw = self._apply_codes(base, [self._code]) + dt = self._discount_total(raw) + self.assertIsNotNone(dt, "applying a valid code must add a discount total") + self.assertLess( + dt.get("amount"), + 0, + f"discount totals[] entry must be negative, got {dt.get('amount')}", + ) + if total_before is not None: + self.assertLess( + self._total_amount(raw), + total_before, + "the order total must decrease when a discount applies", + ) + + # ── DSC-022: allocations sum to the applied discount amount ───────────── + def test_allocations_sum_to_applied_amount(self): + """Each applied discount's allocations sum to its amount (discount.md). + + A cross-field invariant the JSON schema cannot express: when a discount + carries allocations[], their amounts must sum to applied_discount.amount. + Skips honestly for a server that does not emit allocations (they are + optional per the schema). + """ + base, _ = self._new_checkout() + raw = self._apply_codes(base, [self._code]) + applied = (raw.get("discounts") or {}).get("applied") or [] + self.assertTrue(applied, "a valid code must produce an applied discount") + checked = 0 + for a in applied: + allocs = a.get("allocations") or [] + if not allocs: + continue + checked += 1 + self.assertEqual( + sum(al.get("amount", 0) for al in allocs), + a.get("amount"), + f"allocations must sum to applied_discount.amount for " + f"'{a.get('code')}' (got {[al.get('amount') for al in allocs]} vs " + f"{a.get('amount')})", + ) + if checked == 0: + self.skipTest("server does not emit discount allocations[]") + + # ── DSC-027: client-supplied applied[] must not change pricing ────────── + def test_client_applied_does_not_change_price(self): + """Injected discounts.applied[] must not lower the priced total. + + discounts.applied is response-only (discount.json marks it + ucp_request: omit). A server prices discounts from codes[], so a client + that injects a large applied[] amount must not obtain a cheaper order. + Asserts the total equals applying the same code without the injection, + rather than asserting the server rejects the field (the spec constrains + the request sender, not the server's leniency). + """ + base_a, _ = self._new_checkout() + clean = self._apply_codes(base_a, [self._code]) + clean_total = self._total_amount(clean) + self.assertIsNotNone(clean_total, "need a computed total to compare") + base_b, _ = self._new_checkout() + injected = self.update_checkout_session( + base_b, + discounts={ + "codes": [self._code], + "applied": [ + { + "code": "__INJECT__", + "title": "injected", + "amount": 10_000_000, + "allocations": [{"path": "subtotal", "amount": 10_000_000}], + } + ], + }, + headers=integration_test_utils.get_headers(), + ) + self.assertEqual( + self._total_amount(injected), + clean_total, + "client-supplied discounts.applied must not change the priced total " + "(applied is response-only; the server prices from codes[])", + ) + + # ── DSC-004: empty codes[] removes all discounts ──────────────────────── + def test_empty_codes_removes_discount(self): + """Sending codes:[] removes previously applied discounts.""" + base, _ = self._new_checkout() + applied_raw = self._apply_codes(base, [self._code]) + self.assertIsNotNone( + self._discount_total(applied_raw), "precondition: code applied" + ) + cleared = self._apply_codes(checkout.Checkout(**applied_raw), []) + self.assertIsNone( + self._discount_total(cleared), + "codes:[] must remove the discount (no discount total should remain)", + ) + self.assertEqual( + self._applied_codes(cleared), [], "applied[] must be empty after clearing" + ) + + # ── DSC-003: a new codes[] set replaces the previous one ──────────────── + def test_codes_replace_previous_set(self): + """Submitting discounts.codes replaces any previously submitted codes.""" + second = self.fixture_ctx.get_test_discount_code_2() + if not second: + self.skipTest("no valid_discount_code_2 configured for the replace test") + base, _ = self._new_checkout() + self._apply_codes(base, [self._code]) + replaced = self._apply_codes(base, [second]) + applied = [c.upper() for c in self._applied_codes(replaced)] + self.assertIn( + second.upper(), applied, "the replacement code must be applied" + ) + self.assertNotIn( + self._code.upper(), + applied, + f"the first code must be replaced, not accumulated (got {applied})", + ) + + +if __name__ == "__main__": + absltest.main() diff --git a/test_data/flower_shop/conformance_input.json b/test_data/flower_shop/conformance_input.json index d458112..394d612 100644 --- a/test_data/flower_shop/conformance_input.json +++ b/test_data/flower_shop/conformance_input.json @@ -22,5 +22,9 @@ "non_existent_item": { "id": "pink_wumpus", "title": "Pink Wumpus" + }, + "test_fixtures": { + "valid_discount_code": "10OFF", + "valid_discount_code_2": "WELCOME20" } } From 3fa62c34b4213990cd4d26efda03f9e7eb04a955 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Fri, 24 Jul 2026 08:33:11 +0000 Subject: [PATCH 2/3] fix: chain checkout updates in test_codes_replace_previous_set TAG=agy CONV=ba2601d2-c9f2-44dd-b597-7f95df69075a --- discount_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discount_test.py b/discount_test.py index f255e3e..c467a62 100644 --- a/discount_test.py +++ b/discount_test.py @@ -233,8 +233,9 @@ def test_codes_replace_previous_set(self): if not second: self.skipTest("no valid_discount_code_2 configured for the replace test") base, _ = self._new_checkout() - self._apply_codes(base, [self._code]) - replaced = self._apply_codes(base, [second]) + applied_raw = self._apply_codes(base, [self._code]) + applied_obj = checkout.Checkout(**applied_raw) + replaced = self._apply_codes(applied_obj, [second]) applied = [c.upper() for c in self._applied_codes(replaced)] self.assertIn( second.upper(), applied, "the replacement code must be applied" From 627d85b18e1813bf8e9601007e5431094ece21c4 Mon Sep 17 00:00:00 2001 From: damaz91 Date: Fri, 24 Jul 2026 08:40:35 +0000 Subject: [PATCH 3/3] chore: remove redundant test_fixtures from conformance_input.json TAG=agy CONV=ba2601d2-c9f2-44dd-b597-7f95df69075a --- test_data/flower_shop/conformance_input.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test_data/flower_shop/conformance_input.json b/test_data/flower_shop/conformance_input.json index 394d612..d458112 100644 --- a/test_data/flower_shop/conformance_input.json +++ b/test_data/flower_shop/conformance_input.json @@ -22,9 +22,5 @@ "non_existent_item": { "id": "pink_wumpus", "title": "Pink Wumpus" - }, - "test_fixtures": { - "valid_discount_code": "10OFF", - "valid_discount_code_2": "WELCOME20" } }