From b1ae35da88b9136bc06b3cb1fa1820da7df4caff Mon Sep 17 00:00:00 2001 From: Jesse Rosalia Date: Thu, 30 Jul 2026 14:14:19 -0700 Subject: [PATCH 1/3] Add discriminated union serialization tests Tests discriminated union type shapes for both request-side (TypedDict params with Literal discriminator) and response-side (StripeObject deserialization), covering standalone and inline variants. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --- tests/test_discriminated_unions.py | 336 +++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 tests/test_discriminated_unions.py diff --git a/tests/test_discriminated_unions.py b/tests/test_discriminated_unions.py new file mode 100644 index 000000000..593f504d9 --- /dev/null +++ b/tests/test_discriminated_unions.py @@ -0,0 +1,336 @@ +""" +Tests for discriminated union type shapes. + +Covers both sides of the API boundary: +- Request side: TypedDict params with Literal discriminator fields +- Response side: StripeObject deserialization from JSON with a discriminator + +Two structural patterns are tested: +- Standalone union: the discriminated union is its own type (e.g. ColorParams) +- Inline union: the discriminator lives at the parent object level (e.g. shape.type) +""" + +from typing import Union + +from typing_extensions import Literal, NotRequired, TypedDict + +from stripe._stripe_object import StripeObject + + +# --------------------------------------------------------------------------- +# Standalone discriminated union — TypedDict variants +# --------------------------------------------------------------------------- + + +class RgbColorParams(TypedDict): + model: Literal["rgb"] + r: int + g: NotRequired[int] + b: NotRequired[int] + + +class HsvColorParams(TypedDict): + model: Literal["hsv"] + h: int + s: NotRequired[int] + v: NotRequired[int] + + +ColorParams = Union[RgbColorParams, HsvColorParams] + + +# --------------------------------------------------------------------------- +# Inline discriminated union — discriminator at parent level +# --------------------------------------------------------------------------- + + +class CircleShapeParams(TypedDict): + type: Literal["circle"] + radius: float + label: NotRequired[str] + + +class RectangleShapeParams(TypedDict): + type: Literal["rectangle"] + width: float + height: float + label: NotRequired[str] + + +ShapeParams = Union[CircleShapeParams, RectangleShapeParams] + + +# --------------------------------------------------------------------------- +# Request-side: standalone discriminated union +# --------------------------------------------------------------------------- + + +class TestStandaloneUnionRequestSide: + """TypedDict params with a dedicated discriminator field.""" + + def test_rgb_variant_required_fields(self): + params: RgbColorParams = {"model": "rgb", "r": 255} + assert params["model"] == "rgb" + assert params["r"] == 255 + + def test_rgb_variant_all_fields(self): + params: RgbColorParams = {"model": "rgb", "r": 255, "g": 128, "b": 0} + assert params["model"] == "rgb" + assert params["r"] == 255 + assert params["g"] == 128 + assert params["b"] == 0 + + def test_hsv_variant_required_fields(self): + params: HsvColorParams = {"model": "hsv", "h": 180} + assert params["model"] == "hsv" + assert params["h"] == 180 + + def test_hsv_variant_all_fields(self): + params: HsvColorParams = { + "model": "hsv", + "h": 180, + "s": 100, + "v": 50, + } + assert params["model"] == "hsv" + assert params["h"] == 180 + assert params["s"] == 100 + assert params["v"] == 50 + + def test_union_type_rgb_is_dict(self): + params: ColorParams = {"model": "rgb", "r": 255, "g": 0, "b": 0} + assert isinstance(params, dict) + + def test_union_type_hsv_is_dict(self): + params: ColorParams = {"model": "hsv", "h": 0, "s": 100, "v": 100} + assert isinstance(params, dict) + + def test_discriminator_is_serialized(self): + """The discriminator field must appear in the dict sent to the API.""" + params: RgbColorParams = {"model": "rgb", "r": 128} + assert "model" in params + assert params["model"] == "rgb" + + def test_optional_fields_absent_by_default(self): + """When optional fields are omitted they are not present in the dict.""" + params: RgbColorParams = {"model": "rgb", "r": 64} + assert "g" not in params + assert "b" not in params + + def test_optional_fields_present_when_set(self): + params: HsvColorParams = {"model": "hsv", "h": 90, "s": 50} + assert "s" in params + assert "v" not in params + + +# --------------------------------------------------------------------------- +# Request-side: inline discriminated union (discriminator at parent level) +# --------------------------------------------------------------------------- + + +class TestInlineUnionRequestSide: + """Discriminator lives directly on the parent object.""" + + def test_circle_variant(self): + params: CircleShapeParams = {"type": "circle", "radius": 5.0} + assert params["type"] == "circle" + assert params["radius"] == 5.0 + + def test_rectangle_variant(self): + params: RectangleShapeParams = { + "type": "rectangle", + "width": 10.0, + "height": 20.0, + } + assert params["type"] == "rectangle" + assert params["width"] == 10.0 + assert params["height"] == 20.0 + + def test_circle_discriminator_is_serialized(self): + params: CircleShapeParams = {"type": "circle", "radius": 3.0} + assert "type" in params + assert params["type"] == "circle" + + def test_rectangle_discriminator_is_serialized(self): + params: RectangleShapeParams = { + "type": "rectangle", + "width": 4.0, + "height": 8.0, + } + assert "type" in params + assert params["type"] == "rectangle" + + def test_circle_optional_label_absent(self): + params: CircleShapeParams = {"type": "circle", "radius": 1.0} + assert "label" not in params + + def test_circle_optional_label_present(self): + params: CircleShapeParams = { + "type": "circle", + "radius": 1.0, + "label": "small", + } + assert params["label"] == "small" + + def test_union_assignment_circle(self): + params: ShapeParams = {"type": "circle", "radius": 7.5} + assert params["type"] == "circle" + + def test_union_assignment_rectangle(self): + params: ShapeParams = { + "type": "rectangle", + "width": 2.0, + "height": 4.0, + } + assert params["type"] == "rectangle" + + +# --------------------------------------------------------------------------- +# Response-side: StripeObject deserialization +# --------------------------------------------------------------------------- + + +class TestStandaloneUnionResponseDeserialization: + """JSON payloads with a discriminator field deserialize via StripeObject.""" + + def test_rgb_response_discriminator_accessible(self): + json_data = {"model": "rgb", "r": 255, "g": 128, "b": 0} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.model == "rgb" + + def test_rgb_response_payload_fields_accessible(self): + json_data = {"model": "rgb", "r": 255, "g": 128, "b": 0} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.r == 255 + assert obj.g == 128 + assert obj.b == 0 + + def test_hsv_response_discriminator_accessible(self): + json_data = {"model": "hsv", "h": 180, "s": 75, "v": 90} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.model == "hsv" + + def test_hsv_response_payload_fields_accessible(self): + json_data = {"model": "hsv", "h": 180, "s": 75, "v": 90} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.h == 180 + assert obj.s == 75 + assert obj.v == 90 + + def test_response_discriminator_in_dict_output(self): + """to_dict() must include the discriminator field.""" + json_data = {"model": "rgb", "r": 64, "g": 64, "b": 64} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + d = obj.to_dict() + assert "model" in d + assert d["model"] == "rgb" + + def test_response_bracket_access(self): + """Discriminator and payload fields are accessible via bracket notation.""" + json_data = {"model": "rgb", "r": 10} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj["model"] == "rgb" + assert obj["r"] == 10 + + def test_rgb_minimal_response(self): + """Only the discriminator and one required field is sufficient.""" + json_data = {"model": "rgb", "r": 255} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.model == "rgb" + assert obj.r == 255 + + +class TestInlineUnionResponseDeserialization: + """JSON with the discriminator at the parent level deserializes correctly.""" + + def test_circle_discriminator_accessible(self): + json_data = {"type": "circle", "radius": 5.0} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.type == "circle" + + def test_circle_payload_fields_accessible(self): + json_data = {"type": "circle", "radius": 5.0} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.radius == 5.0 + + def test_rectangle_discriminator_accessible(self): + json_data = {"type": "rectangle", "width": 10.0, "height": 20.0} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.type == "rectangle" + + def test_rectangle_payload_fields_accessible(self): + json_data = {"type": "rectangle", "width": 10.0, "height": 20.0} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.width == 10.0 + assert obj.height == 20.0 + + def test_inline_discriminator_in_dict_output(self): + json_data = {"type": "circle", "radius": 3.0} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + d = obj.to_dict() + assert d["type"] == "circle" + assert d["radius"] == 3.0 + + def test_optional_label_present_in_response(self): + json_data = {"type": "circle", "radius": 1.0, "label": "tiny"} + obj = StripeObject.construct_from(json_data, key="sk_test_xxx") + assert obj.label == "tiny" + + +# --------------------------------------------------------------------------- +# Serialization round-trip +# --------------------------------------------------------------------------- + + +class TestDiscriminatedUnionSerializationRoundTrip: + """Dict construction (params → dict) includes the discriminator on output.""" + + def test_rgb_params_round_trip_via_dict(self): + params: RgbColorParams = {"model": "rgb", "r": 200, "g": 100, "b": 50} + # Simulating what the SDK does when encoding params for an API request. + serialized = dict(params) + assert serialized["model"] == "rgb" + assert serialized["r"] == 200 + assert serialized["g"] == 100 + assert serialized["b"] == 50 + + def test_hsv_params_round_trip_via_dict(self): + params: HsvColorParams = {"model": "hsv", "h": 60, "s": 80, "v": 70} + serialized = dict(params) + assert serialized["model"] == "hsv" + assert serialized["h"] == 60 + + def test_circle_params_round_trip_via_dict(self): + params: CircleShapeParams = {"type": "circle", "radius": 2.5} + serialized = dict(params) + assert serialized["type"] == "circle" + assert serialized["radius"] == 2.5 + + def test_rectangle_params_round_trip_via_dict(self): + params: RectangleShapeParams = { + "type": "rectangle", + "width": 4.0, + "height": 8.0, + } + serialized = dict(params) + assert serialized["type"] == "rectangle" + assert serialized["width"] == 4.0 + assert serialized["height"] == 8.0 + + def test_response_to_dict_preserves_discriminator(self): + """ + Round-trip: deserialize JSON into StripeObject, convert back to dict. + The discriminator must survive both directions. + """ + original = {"model": "rgb", "r": 255, "g": 0, "b": 0} + obj = StripeObject.construct_from(original, key="sk_test_xxx") + result = obj.to_dict() + assert result["model"] == "rgb" + assert result == original + + def test_inline_response_to_dict_preserves_discriminator(self): + original = {"type": "rectangle", "width": 3.0, "height": 6.0} + obj = StripeObject.construct_from(original, key="sk_test_xxx") + result = obj.to_dict() + assert result["type"] == "rectangle" + assert result == original From b0286ff86c2337368d4bcf6e8b0735ca317c6b11 Mon Sep 17 00:00:00 2001 From: Jesse Rosalia Date: Fri, 7 Aug 2026 13:11:32 -0700 Subject: [PATCH 2/3] Clarify test docstring scope and dict() comment The module docstring now explicitly states these tests exercise runtime semantics (dict construction, field access, round-trip), not static type narrowing. The dict() comment explains what it's actually testing. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --- tests/test_discriminated_unions.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_discriminated_unions.py b/tests/test_discriminated_unions.py index 593f504d9..698ef5ce2 100644 --- a/tests/test_discriminated_unions.py +++ b/tests/test_discriminated_unions.py @@ -1,5 +1,10 @@ """ -Tests for discriminated union type shapes. +Tests for discriminated union runtime behavior. + +Validates that the generated TypedDict param shapes and StripeObject responses +work correctly at runtime (dict construction, field access, round-trip). +Static type narrowing (Literal discriminators, Union resolution) is verified +separately by pyright/mypy — this file exercises runtime semantics only. Covers both sides of the API boundary: - Request side: TypedDict params with Literal discriminator fields @@ -287,7 +292,8 @@ class TestDiscriminatedUnionSerializationRoundTrip: def test_rgb_params_round_trip_via_dict(self): params: RgbColorParams = {"model": "rgb", "r": 200, "g": 100, "b": 50} - # Simulating what the SDK does when encoding params for an API request. + # TypedDicts are plain dicts at runtime; verify the discriminator and + # variant fields survive a shallow copy (the minimum for serialization). serialized = dict(params) assert serialized["model"] == "rgb" assert serialized["r"] == 200 From 3edf4d21a342c06ee56c49d91962dce200f332a9 Mon Sep 17 00:00:00 2001 From: Jesse Rosalia Date: Fri, 7 Aug 2026 18:33:05 -0700 Subject: [PATCH 3/3] Rewrite DU tests: correct inline pattern + route through _api_encode Inline union tests now use the flattened TypedDict pattern (discriminator and per-variant payload fields on the parent) rather than the incorrect per-variant TypedDicts-with-type-field pattern that was there before. Request-side tests now exercise `_api_encode` so they verify real SDK encoding behavior (bracket notation, nested dicts) rather than just dict construction and key lookup. Co-Authored-By: Claude Sonnet 4.6 Committed-By-Agent: claude --- tests/test_discriminated_unions.py | 315 ++++++++++++----------------- 1 file changed, 134 insertions(+), 181 deletions(-) diff --git a/tests/test_discriminated_unions.py b/tests/test_discriminated_unions.py index 698ef5ce2..2fc83987b 100644 --- a/tests/test_discriminated_unions.py +++ b/tests/test_discriminated_unions.py @@ -15,10 +15,11 @@ - Inline union: the discriminator lives at the parent object level (e.g. shape.type) """ -from typing import Union +from typing import Optional, Union from typing_extensions import Literal, NotRequired, TypedDict +from stripe._encode import _api_encode from stripe._stripe_object import StripeObject @@ -45,24 +46,26 @@ class HsvColorParams(TypedDict): # --------------------------------------------------------------------------- -# Inline discriminated union — discriminator at parent level +# Inline discriminated union — flattened onto parent TypedDict # --------------------------------------------------------------------------- -class CircleShapeParams(TypedDict): - type: Literal["circle"] - radius: float - label: NotRequired[str] +class CardData(TypedDict): + number: str + exp_month: NotRequired[int] -class RectangleShapeParams(TypedDict): - type: Literal["rectangle"] - width: float - height: float - label: NotRequired[str] +class BankData(TypedDict): + routing_number: str + account_number: NotRequired[str] -ShapeParams = Union[CircleShapeParams, RectangleShapeParams] +# Inline union: discriminator + per-variant nullable payload fields on one parent TypedDict +class PaymentParams(TypedDict): + amount: int + type: NotRequired[str] + card: NotRequired[CardData] + bank: NotRequired[BankData] # --------------------------------------------------------------------------- @@ -71,61 +74,46 @@ class RectangleShapeParams(TypedDict): class TestStandaloneUnionRequestSide: - """TypedDict params with a dedicated discriminator field.""" + """Standalone DU params encode through _api_encode with bracket notation.""" - def test_rgb_variant_required_fields(self): - params: RgbColorParams = {"model": "rgb", "r": 255} - assert params["model"] == "rgb" - assert params["r"] == 255 - - def test_rgb_variant_all_fields(self): + def test_rgb_variant_encodes_discriminator(self): params: RgbColorParams = {"model": "rgb", "r": 255, "g": 128, "b": 0} - assert params["model"] == "rgb" - assert params["r"] == 255 - assert params["g"] == 128 - assert params["b"] == 0 - - def test_hsv_variant_required_fields(self): - params: HsvColorParams = {"model": "hsv", "h": 180} - assert params["model"] == "hsv" - assert params["h"] == 180 - - def test_hsv_variant_all_fields(self): - params: HsvColorParams = { - "model": "hsv", - "h": 180, - "s": 100, - "v": 50, - } - assert params["model"] == "hsv" - assert params["h"] == 180 - assert params["s"] == 100 - assert params["v"] == 50 - - def test_union_type_rgb_is_dict(self): - params: ColorParams = {"model": "rgb", "r": 255, "g": 0, "b": 0} - assert isinstance(params, dict) - - def test_union_type_hsv_is_dict(self): - params: ColorParams = {"model": "hsv", "h": 0, "s": 100, "v": 100} - assert isinstance(params, dict) + encoded = dict(_api_encode({"color": params})) + assert encoded["color[model]"] == "rgb" - def test_discriminator_is_serialized(self): - """The discriminator field must appear in the dict sent to the API.""" - params: RgbColorParams = {"model": "rgb", "r": 128} - assert "model" in params - assert params["model"] == "rgb" - - def test_optional_fields_absent_by_default(self): - """When optional fields are omitted they are not present in the dict.""" - params: RgbColorParams = {"model": "rgb", "r": 64} - assert "g" not in params - assert "b" not in params + def test_rgb_variant_encodes_payload_fields(self): + params: RgbColorParams = {"model": "rgb", "r": 255, "g": 128, "b": 0} + encoded = dict(_api_encode({"color": params})) + assert encoded["color[r]"] == 255 + assert encoded["color[g]"] == 128 + assert encoded["color[b]"] == 0 + + def test_hsv_variant_encodes_discriminator(self): + params: HsvColorParams = {"model": "hsv", "h": 180, "s": 100, "v": 50} + encoded = dict(_api_encode({"color": params})) + assert encoded["color[model]"] == "hsv" + + def test_hsv_variant_encodes_payload_fields(self): + params: HsvColorParams = {"model": "hsv", "h": 180, "s": 100, "v": 50} + encoded = dict(_api_encode({"color": params})) + assert encoded["color[h]"] == 180 + assert encoded["color[s]"] == 100 + assert encoded["color[v]"] == 50 + + def test_optional_fields_omitted_when_absent(self): + """None values are skipped by _api_encode.""" + params: RgbColorParams = {"model": "rgb", "r": 255} + encoded = dict(_api_encode({"color": params})) + assert encoded["color[model]"] == "rgb" + assert encoded["color[r]"] == 255 + assert "color[g]" not in encoded + assert "color[b]" not in encoded - def test_optional_fields_present_when_set(self): - params: HsvColorParams = {"model": "hsv", "h": 90, "s": 50} - assert "s" in params - assert "v" not in params + def test_union_type_rgb_encodes_correctly(self): + params: ColorParams = {"model": "rgb", "r": 255, "g": 0, "b": 0} + encoded = dict(_api_encode({"color": params})) + assert encoded["color[model]"] == "rgb" + assert encoded["color[r]"] == 255 # --------------------------------------------------------------------------- @@ -134,60 +122,42 @@ def test_optional_fields_present_when_set(self): class TestInlineUnionRequestSide: - """Discriminator lives directly on the parent object.""" - - def test_circle_variant(self): - params: CircleShapeParams = {"type": "circle", "radius": 5.0} - assert params["type"] == "circle" - assert params["radius"] == 5.0 - - def test_rectangle_variant(self): - params: RectangleShapeParams = { - "type": "rectangle", - "width": 10.0, - "height": 20.0, - } - assert params["type"] == "rectangle" - assert params["width"] == 10.0 - assert params["height"] == 20.0 - - def test_circle_discriminator_is_serialized(self): - params: CircleShapeParams = {"type": "circle", "radius": 3.0} - assert "type" in params - assert params["type"] == "circle" - - def test_rectangle_discriminator_is_serialized(self): - params: RectangleShapeParams = { - "type": "rectangle", - "width": 4.0, - "height": 8.0, - } - assert "type" in params - assert params["type"] == "rectangle" - - def test_circle_optional_label_absent(self): - params: CircleShapeParams = {"type": "circle", "radius": 1.0} - assert "label" not in params - - def test_circle_optional_label_present(self): - params: CircleShapeParams = { - "type": "circle", - "radius": 1.0, - "label": "small", - } - assert params["label"] == "small" - - def test_union_assignment_circle(self): - params: ShapeParams = {"type": "circle", "radius": 7.5} - assert params["type"] == "circle" - - def test_union_assignment_rectangle(self): - params: ShapeParams = { - "type": "rectangle", - "width": 2.0, - "height": 4.0, - } - assert params["type"] == "rectangle" + """Inline DU params encode with discriminator at top level and nested variant payloads.""" + + def test_card_variant_encodes_discriminator_at_top_level(self): + params: PaymentParams = {"amount": 1000, "type": "card", "card": {"number": "4242424242424242"}} + encoded = dict(_api_encode(params)) + assert encoded["type"] == "card" + + def test_card_variant_encodes_nested_payload(self): + params: PaymentParams = {"amount": 1000, "type": "card", "card": {"number": "4242424242424242", "exp_month": 12}} + encoded = dict(_api_encode(params)) + assert encoded["card[number]"] == "4242424242424242" + assert encoded["card[exp_month]"] == 12 + + def test_card_variant_encodes_base_fields(self): + params: PaymentParams = {"amount": 1000, "type": "card", "card": {"number": "4242"}} + encoded = dict(_api_encode(params)) + assert encoded["amount"] == 1000 + + def test_bank_variant_encodes_correctly(self): + params: PaymentParams = {"amount": 500, "type": "bank", "bank": {"routing_number": "110000000", "account_number": "000123456789"}} + encoded = dict(_api_encode(params)) + assert encoded["type"] == "bank" + assert encoded["bank[routing_number]"] == "110000000" + assert encoded["bank[account_number]"] == "000123456789" + + def test_non_selected_variant_not_encoded(self): + """When card is selected, bank keys do not appear in encoded output.""" + params: PaymentParams = {"amount": 1000, "type": "card", "card": {"number": "4242"}} + encoded = dict(_api_encode(params)) + assert "bank[routing_number]" not in encoded + assert "bank[account_number]" not in encoded + + def test_optional_nested_fields_omitted(self): + params: PaymentParams = {"amount": 100, "type": "card", "card": {"number": "4242"}} + encoded = dict(_api_encode(params)) + assert "card[exp_month]" not in encoded # --------------------------------------------------------------------------- @@ -246,40 +216,42 @@ def test_rgb_minimal_response(self): class TestInlineUnionResponseDeserialization: - """JSON with the discriminator at the parent level deserializes correctly.""" + """JSON with the discriminator at the parent level and variant data nested.""" - def test_circle_discriminator_accessible(self): - json_data = {"type": "circle", "radius": 5.0} + def test_card_discriminator_accessible(self): + json_data = {"type": "card", "card": {"number": "4242424242424242", "exp_month": 12}, "amount": 1000} obj = StripeObject.construct_from(json_data, key="sk_test_xxx") - assert obj.type == "circle" + assert obj.type == "card" - def test_circle_payload_fields_accessible(self): - json_data = {"type": "circle", "radius": 5.0} + def test_card_payload_is_stripe_object(self): + json_data = {"type": "card", "card": {"number": "4242424242424242", "exp_month": 12}, "amount": 1000} obj = StripeObject.construct_from(json_data, key="sk_test_xxx") - assert obj.radius == 5.0 + assert obj.card.number == "4242424242424242" + assert obj.card.exp_month == 12 - def test_rectangle_discriminator_accessible(self): - json_data = {"type": "rectangle", "width": 10.0, "height": 20.0} + def test_bank_discriminator_accessible(self): + json_data = {"type": "bank", "bank": {"routing_number": "110000000", "account_number": "000123456789"}, "amount": 500} obj = StripeObject.construct_from(json_data, key="sk_test_xxx") - assert obj.type == "rectangle" + assert obj.type == "bank" - def test_rectangle_payload_fields_accessible(self): - json_data = {"type": "rectangle", "width": 10.0, "height": 20.0} + def test_bank_payload_is_stripe_object(self): + json_data = {"type": "bank", "bank": {"routing_number": "110000000", "account_number": "000123456789"}, "amount": 500} obj = StripeObject.construct_from(json_data, key="sk_test_xxx") - assert obj.width == 10.0 - assert obj.height == 20.0 + assert obj.bank.routing_number == "110000000" + assert obj.bank.account_number == "000123456789" - def test_inline_discriminator_in_dict_output(self): - json_data = {"type": "circle", "radius": 3.0} + def test_non_selected_variant_absent(self): + json_data = {"type": "card", "card": {"number": "4242"}, "amount": 100} obj = StripeObject.construct_from(json_data, key="sk_test_xxx") - d = obj.to_dict() - assert d["type"] == "circle" - assert d["radius"] == 3.0 + assert obj.type == "card" + assert not hasattr(obj, "bank") or obj.get("bank") is None - def test_optional_label_present_in_response(self): - json_data = {"type": "circle", "radius": 1.0, "label": "tiny"} + def test_inline_discriminator_in_dict_output(self): + json_data = {"type": "card", "card": {"number": "4242"}, "amount": 100} obj = StripeObject.construct_from(json_data, key="sk_test_xxx") - assert obj.label == "tiny" + d = obj.to_dict() + assert d["type"] == "card" + assert d["card"]["number"] == "4242" # --------------------------------------------------------------------------- @@ -288,55 +260,36 @@ def test_optional_label_present_in_response(self): class TestDiscriminatedUnionSerializationRoundTrip: - """Dict construction (params → dict) includes the discriminator on output.""" + """Full pipeline: params encode via _api_encode, responses deserialize via construct_from.""" - def test_rgb_params_round_trip_via_dict(self): + def test_standalone_params_encode_round_trip(self): params: RgbColorParams = {"model": "rgb", "r": 200, "g": 100, "b": 50} - # TypedDicts are plain dicts at runtime; verify the discriminator and - # variant fields survive a shallow copy (the minimum for serialization). - serialized = dict(params) - assert serialized["model"] == "rgb" - assert serialized["r"] == 200 - assert serialized["g"] == 100 - assert serialized["b"] == 50 - - def test_hsv_params_round_trip_via_dict(self): - params: HsvColorParams = {"model": "hsv", "h": 60, "s": 80, "v": 70} - serialized = dict(params) - assert serialized["model"] == "hsv" - assert serialized["h"] == 60 - - def test_circle_params_round_trip_via_dict(self): - params: CircleShapeParams = {"type": "circle", "radius": 2.5} - serialized = dict(params) - assert serialized["type"] == "circle" - assert serialized["radius"] == 2.5 - - def test_rectangle_params_round_trip_via_dict(self): - params: RectangleShapeParams = { - "type": "rectangle", - "width": 4.0, - "height": 8.0, - } - serialized = dict(params) - assert serialized["type"] == "rectangle" - assert serialized["width"] == 4.0 - assert serialized["height"] == 8.0 - - def test_response_to_dict_preserves_discriminator(self): - """ - Round-trip: deserialize JSON into StripeObject, convert back to dict. - The discriminator must survive both directions. - """ + encoded = dict(_api_encode({"color": params})) + assert encoded["color[model]"] == "rgb" + assert encoded["color[r]"] == 200 + assert encoded["color[g]"] == 100 + assert encoded["color[b]"] == 50 + + def test_inline_params_encode_round_trip(self): + params: PaymentParams = {"amount": 100, "type": "card", "card": {"number": "4242"}} + encoded = dict(_api_encode(params)) + assert encoded["type"] == "card" + assert encoded["card[number]"] == "4242" + assert encoded["amount"] == 100 + + def test_standalone_response_round_trip(self): + """Deserialize and re-serialize preserves discriminator.""" original = {"model": "rgb", "r": 255, "g": 0, "b": 0} obj = StripeObject.construct_from(original, key="sk_test_xxx") result = obj.to_dict() assert result["model"] == "rgb" assert result == original - def test_inline_response_to_dict_preserves_discriminator(self): - original = {"type": "rectangle", "width": 3.0, "height": 6.0} + def test_inline_response_round_trip(self): + """Deserialize inline DU response and re-serialize preserves structure.""" + original = {"type": "card", "card": {"number": "4242"}, "amount": 100} obj = StripeObject.construct_from(original, key="sk_test_xxx") result = obj.to_dict() - assert result["type"] == "rectangle" - assert result == original + assert result["type"] == "card" + assert result["card"] == {"number": "4242"} + assert result["amount"] == 100