Skip to content

Commit 8672472

Browse files
committed
test: add failing coverage for fulfillment_method destination retyping
fulfillment_method.json's destinations property is typed via items.$ref to fulfillment_destination.json (bare type: str, id: str), but two if/then allOf branches retype it per the method's own type: when type is shipping, destinations should really be shipping_destination.json items (postal address fields, type const shipping_address); when type is pickup, destinations should really be location_destination.json items (type const business_location). The committed FulfillmentMethod model ignores both branches entirely, so a shipping method can list a destination typed business_location (or vice versa) and it validates in violation of the schema. This is a third if/then shape neither find_conditional_required (adds required fields) nor find_conditional_bounds (narrows numeric ranges) handles: a discriminator retyping an ARRAY PROPERTY's items to a different referenced schema file entirely. No scanner in postprocess_models.py ever looked for it. Adds, mirroring the existing conditional-rule injector tests: - ConditionalArrayRetypingInjectorTest: injector-level unit tests against synthetic fixtures mirroring fulfillment_method.json's exact shape (method/destination/shipping_destination/ location_destination), covering the schema scan (both branches read, a branch whose ref matches the base is not a retype, a stripped request-variant field is inapplicable not malformed, an unresolvable $ref warns), injection idempotency, and the injected validator's runtime behavior against synthetic Method/Destination classes. - FulfillmentMethodDestinationRetypingSemanticTest: exercises the real committed FulfillmentMethod and FulfillmentDestination models. Includes negative controls for an open-vocabulary type (no rule applies) and no destinations at all (unconstrained). RED (test-only; find_conditional_array_retyping and inject_conditional_array_retyping do not exist on this commit -- the generator fix that adds them, developed alongside these tests per the exact schema shape confirmed against the pinned 2026-08-25 UCP spec, follows in the next commit): 101 tests, 2 failures + 6 errors, 4 documented skips (unchanged from the root-cause-0 commit).
1 parent eba0b77 commit 8672472

1 file changed

Lines changed: 354 additions & 0 deletions

File tree

tests/test_codegen_pipeline.py

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,268 @@ def test_injected_validator_enforces_conditional_bounds(self):
15761576
total(type="total", amount=-5)
15771577

15781578

1579+
class ConditionalArrayRetypingInjectorTest(unittest.TestCase):
1580+
"""A discriminator retyping an array property's items to a different
1581+
referenced schema file is a third if/then shape, distinct from
1582+
conditional-required and conditional-bounds above. This mirrors
1583+
fulfillment_method.json: the base `destinations` property is typed via
1584+
`items.$ref` to fulfillment_destination.json, but a `shipping` method's
1585+
destinations should really be shipping_destination.json items (postal
1586+
address fields, `type` const `shipping_address`) and a `pickup`
1587+
method's should really be location_destination.json items (`type`
1588+
const `business_location`). The generator drops both retyping branches
1589+
entirely -- no scanner in this module ever looked for this shape.
1590+
"""
1591+
1592+
MODULE = (
1593+
"from __future__ import annotations\n"
1594+
"\n"
1595+
"from pydantic import BaseModel, ConfigDict\n"
1596+
"\n"
1597+
"from . import destination\n"
1598+
"\n"
1599+
"\n"
1600+
"class Method(BaseModel):\n"
1601+
' model_config = ConfigDict(extra="allow")\n'
1602+
" type: str\n"
1603+
" destinations: list[destination.Destination] | None = None\n"
1604+
)
1605+
RULES = [
1606+
{
1607+
"discriminator": "type",
1608+
"values": ["shipping"],
1609+
"field": "destinations",
1610+
"required": ["id", "type"],
1611+
"consts": {"type": "shipping_address"},
1612+
},
1613+
{
1614+
"discriminator": "type",
1615+
"values": ["pickup"],
1616+
"field": "destinations",
1617+
"required": ["type"],
1618+
"consts": {"type": "business_location"},
1619+
},
1620+
]
1621+
1622+
def _schema(self):
1623+
return {
1624+
"title": "Method",
1625+
"type": "object",
1626+
"properties": {
1627+
"type": {"type": "string"},
1628+
"destinations": {
1629+
"type": "array",
1630+
"items": {"$ref": "destination.json"},
1631+
},
1632+
},
1633+
"allOf": [
1634+
{
1635+
"if": {
1636+
"properties": {"type": {"const": "shipping"}},
1637+
"required": ["type"],
1638+
},
1639+
"then": {
1640+
"properties": {
1641+
"destinations": {
1642+
"type": "array",
1643+
"items": {"$ref": "shipping_destination.json"},
1644+
}
1645+
}
1646+
},
1647+
},
1648+
{
1649+
"if": {
1650+
"properties": {"type": {"const": "pickup"}},
1651+
"required": ["type"],
1652+
},
1653+
"then": {
1654+
"properties": {
1655+
"destinations": {
1656+
"type": "array",
1657+
"items": {"$ref": "location_destination.json"},
1658+
}
1659+
}
1660+
},
1661+
},
1662+
],
1663+
}
1664+
1665+
def _write_schema_tree(self, tmp):
1666+
Path(tmp, "method.json").write_text(json.dumps(self._schema()))
1667+
Path(tmp, "destination.json").write_text(
1668+
json.dumps(
1669+
{
1670+
"title": "Destination",
1671+
"type": "object",
1672+
"required": ["id", "type"],
1673+
"properties": {
1674+
"id": {"type": "string"},
1675+
"type": {"type": "string"},
1676+
},
1677+
}
1678+
)
1679+
)
1680+
Path(tmp, "shipping_destination.json").write_text(
1681+
json.dumps(
1682+
{
1683+
"title": "Shipping Destination",
1684+
"type": "object",
1685+
"required": ["id", "type"],
1686+
"properties": {
1687+
"id": {"type": "string"},
1688+
"type": {"type": "string", "const": "shipping_address"},
1689+
},
1690+
"allOf": [{"$ref": "postal_address.json"}],
1691+
}
1692+
)
1693+
)
1694+
Path(tmp, "location_destination.json").write_text(
1695+
json.dumps(
1696+
{
1697+
"title": "Business Location Destination",
1698+
"type": "object",
1699+
"required": ["type"],
1700+
"properties": {
1701+
"type": {"type": "string", "const": "business_location"}
1702+
},
1703+
"allOf": [{"$ref": "location_summary.json"}],
1704+
}
1705+
)
1706+
)
1707+
1708+
def test_schema_scan_reads_both_retyping_branches(self):
1709+
with tempfile.TemporaryDirectory() as tmp:
1710+
self._write_schema_tree(tmp)
1711+
found = postprocess_models.find_conditional_array_retyping(
1712+
Path(tmp)
1713+
)
1714+
self.assertEqual(found, {"Method": self.RULES})
1715+
1716+
def test_schema_scan_ignores_branch_matching_the_base_ref(self):
1717+
# A then.properties.<field>.items.$ref identical to the base ref is
1718+
# not a retype -- nothing to approximate. The sibling pickup branch
1719+
# (still a genuine retype) is unaffected.
1720+
schema = self._schema()
1721+
schema["allOf"][0]["then"]["properties"]["destinations"]["items"][
1722+
"$ref"
1723+
] = "destination.json"
1724+
with tempfile.TemporaryDirectory() as tmp:
1725+
self._write_schema_tree(tmp)
1726+
Path(tmp, "method.json").write_text(json.dumps(schema))
1727+
found = postprocess_models.find_conditional_array_retyping(
1728+
Path(tmp)
1729+
)
1730+
self.assertEqual(found, {"Method": [self.RULES[1]]})
1731+
1732+
def test_schema_scan_skips_rule_whose_field_was_stripped(self):
1733+
# A request variant that omits `destinations` entirely (as
1734+
# fulfillment_method_create_request.json does) makes the rule
1735+
# inapplicable, not malformed -- no warning, and no rule recorded
1736+
# for the variant's own class.
1737+
schema = self._schema()
1738+
schema["title"] = "Method Create Request"
1739+
del schema["properties"]["destinations"]
1740+
with tempfile.TemporaryDirectory() as tmp:
1741+
self._write_schema_tree(tmp)
1742+
Path(tmp, "method_create_request.json").write_text(
1743+
json.dumps(schema)
1744+
)
1745+
stderr = io.StringIO()
1746+
with contextlib.redirect_stderr(stderr):
1747+
found = postprocess_models.find_conditional_array_retyping(
1748+
Path(tmp)
1749+
)
1750+
self.assertNotIn("MethodCreateRequest", found)
1751+
self.assertEqual(found, {"Method": self.RULES})
1752+
self.assertNotIn("unsupported", stderr.getvalue())
1753+
1754+
def test_schema_scan_warns_when_retyped_ref_cannot_be_loaded(self):
1755+
schema = self._schema()
1756+
with tempfile.TemporaryDirectory() as tmp:
1757+
Path(tmp, "method.json").write_text(json.dumps(schema))
1758+
Path(tmp, "destination.json").write_text(
1759+
json.dumps({"title": "Destination", "type": "object"})
1760+
)
1761+
# shipping_destination.json / location_destination.json are
1762+
# deliberately absent.
1763+
stderr = io.StringIO()
1764+
with contextlib.redirect_stderr(stderr):
1765+
found = postprocess_models.find_conditional_array_retyping(
1766+
Path(tmp)
1767+
)
1768+
self.assertEqual(found, {})
1769+
self.assertIn("could not be loaded", stderr.getvalue())
1770+
1771+
def test_injection_is_idempotent(self):
1772+
once = postprocess_models.inject_conditional_array_retyping(
1773+
self.MODULE, "Method", self.RULES
1774+
)
1775+
twice = postprocess_models.inject_conditional_array_retyping(
1776+
once, "Method", self.RULES
1777+
)
1778+
self.assertEqual(once, twice)
1779+
1780+
@unittest.skipUnless(HAVE_SDK, "executing the module needs pydantic")
1781+
def test_injected_validator_enforces_retyping(self):
1782+
module = (
1783+
"from __future__ import annotations\n"
1784+
"\n"
1785+
"from pydantic import BaseModel, ConfigDict\n"
1786+
"\n"
1787+
"\n"
1788+
"class Destination(BaseModel):\n"
1789+
' model_config = ConfigDict(extra="allow")\n'
1790+
" type: str\n"
1791+
" id: str\n"
1792+
"\n"
1793+
"\n"
1794+
"class Method(BaseModel):\n"
1795+
' model_config = ConfigDict(extra="allow")\n'
1796+
" type: str\n"
1797+
" destinations: list[Destination] | None = None\n"
1798+
)
1799+
out = postprocess_models.inject_conditional_array_retyping(
1800+
module, "Method", self.RULES
1801+
)
1802+
namespace: dict = {}
1803+
exec(compile(out, "<injected>", "exec"), namespace) # noqa: S102
1804+
# Forward reference (from __future__ import annotations): Method's
1805+
# "destinations: list[Destination]" annotation resolves once both
1806+
# classes exist in the exec'd namespace.
1807+
namespace["Method"].model_rebuild(_types_namespace=namespace)
1808+
method_cls = namespace["Method"]
1809+
destination_cls = namespace["Destination"]
1810+
with self.assertRaises(ValidationError):
1811+
method_cls(
1812+
type="shipping",
1813+
destinations=[
1814+
destination_cls(type="business_location", id="d1")
1815+
],
1816+
)
1817+
with self.assertRaises(ValidationError):
1818+
method_cls(
1819+
type="pickup",
1820+
destinations=[
1821+
destination_cls(type="shipping_address", id="d1")
1822+
],
1823+
)
1824+
method_cls(
1825+
type="shipping",
1826+
destinations=[destination_cls(type="shipping_address", id="d1")],
1827+
)
1828+
method_cls(
1829+
type="pickup",
1830+
destinations=[destination_cls(type="business_location", id="d1")],
1831+
)
1832+
# A type carrying no rule is unconstrained (open vocabulary).
1833+
method_cls(
1834+
type="courier",
1835+
destinations=[destination_cls(type="anything", id="d1")],
1836+
)
1837+
# No destinations at all is unconstrained regardless of type.
1838+
method_cls(type="shipping")
1839+
1840+
15791841
class InjectorTest(unittest.TestCase):
15801842
"""The post-generation injector's own behavior."""
15811843

@@ -2474,5 +2736,97 @@ def test_other_unit_with_nonzero_scale_accepted(self):
24742736
self.assertEqual(unit.scale, 3)
24752737

24762738

2739+
@unittest.skipUnless(
2740+
HAVE_SDK, "requires the installed package (pip install -e .)"
2741+
)
2742+
class FulfillmentMethodDestinationRetypingSemanticTest(unittest.TestCase):
2743+
"""fulfillment_method.json retypes `destinations` per `type`: a
2744+
`shipping` method's destinations are shipping_destination.json items
2745+
(`type` const `shipping_address`), a `pickup` method's are
2746+
location_destination.json items (`type` const `business_location`).
2747+
The committed FulfillmentMethod model, before this fix, accepted any
2748+
FulfillmentDestination (bare `type: str`, `id: str`) regardless of the
2749+
method's own type, so a `shipping` method could list a
2750+
`business_location` destination and it would validate.
2751+
"""
2752+
2753+
def _method(self):
2754+
from ucp_sdk.models.schemas.shopping.types.fulfillment_method import (
2755+
FulfillmentMethod,
2756+
)
2757+
2758+
return FulfillmentMethod
2759+
2760+
def _destination(self):
2761+
from ucp_sdk.models.schemas.shopping.types.fulfillment_destination import (
2762+
FulfillmentDestination,
2763+
)
2764+
2765+
return FulfillmentDestination
2766+
2767+
def test_shipping_method_with_business_location_destination_rejected(
2768+
self,
2769+
):
2770+
with self.assertRaises(ValidationError):
2771+
self._method()(
2772+
id="m1",
2773+
type="shipping",
2774+
line_item_ids=["li1"],
2775+
destinations=[
2776+
self._destination()(type="business_location", id="d1")
2777+
],
2778+
)
2779+
2780+
def test_pickup_method_with_shipping_address_destination_rejected(self):
2781+
with self.assertRaises(ValidationError):
2782+
self._method()(
2783+
id="m2",
2784+
type="pickup",
2785+
line_item_ids=["li1"],
2786+
destinations=[
2787+
self._destination()(type="shipping_address", id="d1")
2788+
],
2789+
)
2790+
2791+
def test_shipping_method_with_shipping_address_destination_accepted(
2792+
self,
2793+
):
2794+
method = self._method()(
2795+
id="m1",
2796+
type="shipping",
2797+
line_item_ids=["li1"],
2798+
destinations=[
2799+
self._destination()(type="shipping_address", id="d1")
2800+
],
2801+
)
2802+
self.assertEqual(method.destinations[0].type, "shipping_address")
2803+
2804+
def test_pickup_method_with_business_location_destination_accepted(self):
2805+
method = self._method()(
2806+
id="m2",
2807+
type="pickup",
2808+
line_item_ids=["li1"],
2809+
destinations=[
2810+
self._destination()(type="business_location", id="d1")
2811+
],
2812+
)
2813+
self.assertEqual(method.destinations[0].type, "business_location")
2814+
2815+
def test_method_type_outside_the_pinned_vocabulary_is_unconstrained(
2816+
self,
2817+
):
2818+
# type is an open vocabulary ("Businesses MAY use additional
2819+
# values"); only shipping/pickup carry a retyping rule.
2820+
self._method()(
2821+
id="m3",
2822+
type="curbside",
2823+
line_item_ids=["li1"],
2824+
destinations=[self._destination()(type="anything", id="d1")],
2825+
)
2826+
2827+
def test_method_without_destinations_is_unconstrained(self):
2828+
self._method()(id="m4", type="shipping", line_item_ids=["li1"])
2829+
2830+
24772831
if __name__ == "__main__":
24782832
unittest.main()

0 commit comments

Comments
 (0)