honcho/tests/utils/test_schema_conversion.py

898 lines
34 KiB
Python

"""Unit tests for src/utils/schema_conversion.py."""
import json
import re
from typing import Any
import pytest
from pydantic import BaseModel, ValidationError
from src.utils.schema_conversion import json_response_schema_to_pydantic
def _object(properties: dict[str, Any], **extra: Any) -> dict[str, Any]:
return {"type": "object", "properties": properties, **extra}
class TestPrimitives:
def test_flat_object_with_primitives(self):
model = json_response_schema_to_pydantic(
_object(
{
"name": {"type": "string"},
"age": {"type": "integer"},
"score": {"type": "number"},
"active": {"type": "boolean"},
},
required=["name", "age"],
)
)
instance = model.model_validate(
{"name": "ada", "age": 36, "score": 9.5, "active": True}
)
assert instance.name == "ada" # pyright: ignore
assert instance.age == 36 # pyright: ignore
def test_required_field_missing_fails(self):
model = json_response_schema_to_pydantic(
_object({"name": {"type": "string"}}, required=["name"])
)
with pytest.raises(ValidationError):
model.model_validate({})
def test_optional_field_defaults_to_none(self):
model = json_response_schema_to_pydantic(
_object({"nickname": {"type": "string"}})
)
instance = model.model_validate({})
assert instance.nickname is None # pyright: ignore
def test_default_value(self):
model = json_response_schema_to_pydantic(
_object({"count": {"type": "integer", "default": 3}})
)
assert model.model_validate({}).count == 3 # pyright: ignore
def test_null_type(self):
model = json_response_schema_to_pydantic(
_object({"nothing": {"type": "null"}}, required=["nothing"])
)
assert model.model_validate({"nothing": None}).nothing is None # pyright: ignore
def test_default_wins_over_required(self):
model = json_response_schema_to_pydantic(
_object({"count": {"type": "integer", "default": 3}}, required=["count"])
)
assert model.model_validate({}).count == 3 # pyright: ignore
class TestNesting:
def test_nested_object(self):
model = json_response_schema_to_pydantic(
_object(
{
"address": _object(
{
"city": {"type": "string"},
"geo": _object(
{"lat": {"type": "number"}}, required=["lat"]
),
},
required=["city", "geo"],
)
},
required=["address"],
)
)
instance = model.model_validate(
{"address": {"city": "oakland", "geo": {"lat": 37.8}}}
)
assert instance.address.geo.lat == 37.8 # pyright: ignore
def test_array_of_objects(self):
model = json_response_schema_to_pydantic(
_object(
{
"items": {
"type": "array",
"items": _object(
{"food": {"type": "string"}}, required=["food"]
),
}
},
required=["items"],
)
)
instance = model.model_validate({"items": [{"food": "sushi"}]})
assert instance.items[0].food == "sushi" # pyright: ignore
def test_array_without_items_accepts_anything(self):
model = json_response_schema_to_pydantic(
_object({"stuff": {"type": "array"}}, required=["stuff"])
)
instance = model.model_validate({"stuff": [1, "two", {"three": 3}]})
assert len(instance.stuff) == 3 # pyright: ignore
def test_nested_model_name_collision(self):
# Two sibling objects whose name hints collide must not clash.
model = json_response_schema_to_pydantic(
_object(
{
"a": _object({"x b": _object({"v": {"type": "string"}})}),
"a_x": _object({"b": _object({"v": {"type": "integer"}})}),
}
)
)
instance = model.model_validate(
{"a": {"x b": {"v": "s"}}, "a_x": {"b": {"v": 1}}}
)
assert instance.a_x.b.v == 1 # pyright: ignore
class TestEnumsAndUnions:
def test_string_enum(self):
model = json_response_schema_to_pydantic(
_object(
{"sentiment": {"enum": ["loves", "hates"]}},
required=["sentiment"],
)
)
assert model.model_validate({"sentiment": "loves"}).sentiment == "loves" # pyright: ignore
with pytest.raises(ValidationError):
model.model_validate({"sentiment": "meh"})
def test_int_enum_and_null_member(self):
model = json_response_schema_to_pydantic(
_object({"level": {"enum": [1, 2, None]}}, required=["level"])
)
assert model.model_validate({"level": None}).level is None # pyright: ignore
assert model.model_validate({"level": 2}).level == 2 # pyright: ignore
def test_invalid_enum_value_type(self):
with pytest.raises(ValueError, match="enum values"):
json_response_schema_to_pydantic(_object({"bad": {"enum": [[1]]}}))
def test_anyof_with_null_is_optional(self):
model = json_response_schema_to_pydantic(
_object(
{"maybe": {"anyOf": [{"type": "string"}, {"type": "null"}]}},
required=["maybe"],
)
)
assert model.model_validate({"maybe": None}).maybe is None # pyright: ignore
assert model.model_validate({"maybe": "x"}).maybe == "x" # pyright: ignore
def test_oneof_union(self):
model = json_response_schema_to_pydantic(
_object(
{"value": {"oneOf": [{"type": "integer"}, {"type": "string"}]}},
required=["value"],
)
)
assert model.model_validate({"value": 5}).value == 5 # pyright: ignore
def test_type_list_form(self):
model = json_response_schema_to_pydantic(
_object({"name": {"type": ["string", "null"]}}, required=["name"])
)
assert model.model_validate({"name": None}).name is None # pyright: ignore
def test_all_null_enum_degenerates_to_none(self):
model = json_response_schema_to_pydantic(
_object({"nothing": {"enum": [None]}}, required=["nothing"])
)
assert model.model_validate({"nothing": None}).nothing is None # pyright: ignore
with pytest.raises(ValidationError):
model.model_validate({"nothing": "x"})
def test_union_of_objects(self):
model = json_response_schema_to_pydantic(
_object(
{
"pet": {
"anyOf": [
_object({"meows": {"type": "boolean"}}, required=["meows"]),
_object({"barks": {"type": "boolean"}}, required=["barks"]),
]
}
},
required=["pet"],
)
)
instance = model.model_validate({"pet": {"barks": True}})
assert instance.pet.barks is True # pyright: ignore
class TestRejections:
@pytest.mark.parametrize(
"construct,schema",
[
("$defs", _object({"a": {"type": "object", "$defs": {}}})),
("definitions", _object({"a": {"type": "object", "definitions": {}}})),
("allOf", _object({"a": {"allOf": [{"type": "string"}]}})),
("not", _object({"a": {"not": {"type": "string"}}})),
("if", _object({"a": {"if": {"type": "string"}}})),
(
"patternProperties",
_object({"a": {"type": "object", "patternProperties": {}}}),
),
],
)
def test_unsupported_constructs(self, construct: str, schema: dict[str, Any]):
with pytest.raises(ValueError, match=re.escape(construct)):
json_response_schema_to_pydantic(schema)
def test_error_message_includes_path(self):
with pytest.raises(
ValueError, match=r"unsupported \$ref '#/x'.*at properties\.address"
):
json_response_schema_to_pydantic(_object({"address": {"$ref": "#/x"}}))
def test_schema_valued_additional_properties(self):
with pytest.raises(ValueError, match="additionalProperties"):
json_response_schema_to_pydantic(
_object(
{
"map": {
"type": "object",
"additionalProperties": {"type": "string"},
}
}
)
)
def test_boolean_schema(self):
with pytest.raises(ValueError, match="boolean schemas"):
json_response_schema_to_pydantic(_object({"anything": True}))
def test_unknown_type(self):
with pytest.raises(ValueError, match="unsupported type 'date'"):
json_response_schema_to_pydantic(_object({"when": {"type": "date"}}))
class TestRefs:
def test_ref_into_defs(self):
model = json_response_schema_to_pydantic(
_object(
{"address": {"$ref": "#/$defs/Address"}},
required=["address"],
**{
"$defs": {
"Address": _object(
{"city": {"type": "string"}}, required=["city"]
)
}
},
)
)
instance = model.model_validate({"address": {"city": "Berlin"}})
assert instance.address.city == "Berlin" # pyright: ignore
def test_ref_into_definitions_alias(self):
model = json_response_schema_to_pydantic(
_object(
{"item": {"$ref": "#/definitions/Item"}},
required=["item"],
definitions={"Item": {"type": "string"}},
)
)
assert model.model_validate({"item": "x"}).item == "x" # pyright: ignore
def test_pydantic_nested_model_schema(self):
"""The real-world motivation: model_json_schema() of a nested Pydantic
model emits $defs/$ref and must convert cleanly."""
class Preference(BaseModel):
food: str
confidence: float
class Preferences(BaseModel):
preferences: list[Preference]
summary: str
model = json_response_schema_to_pydantic(Preferences.model_json_schema())
instance = model.model_validate(
{
"preferences": [{"food": "sushi", "confidence": 0.9}],
"summary": "likes sushi",
}
)
assert instance.preferences[0].food == "sushi" # pyright: ignore
def test_root_ref(self):
model = json_response_schema_to_pydantic(
{
"$ref": "#/$defs/Root",
"$defs": {
"Root": _object({"ok": {"type": "boolean"}}, required=["ok"])
},
}
)
assert model.model_validate({"ok": True}).ok is True # pyright: ignore
def test_ref_sibling_keys_overlay_target(self):
model = json_response_schema_to_pydantic(
_object(
{"count": {"$ref": "#/$defs/Count", "default": 3}},
**{"$defs": {"Count": {"type": "integer"}}},
)
)
assert model.model_validate({}).count == 3 # pyright: ignore
def test_same_def_referenced_twice(self):
model = json_response_schema_to_pydantic(
_object(
{
"home": {"$ref": "#/$defs/Address"},
"work": {"$ref": "#/$defs/Address"},
},
required=["home", "work"],
**{"$defs": {"Address": _object({"city": {"type": "string"}})}},
)
)
instance = model.model_validate(
{"home": {"city": "Berlin"}, "work": {"city": "Kyiv"}}
)
assert instance.work.city == "Kyiv" # pyright: ignore
def test_chained_refs(self):
model = json_response_schema_to_pydantic(
_object(
{"a": {"$ref": "#/$defs/A"}},
required=["a"],
**{"$defs": {"A": {"$ref": "#/$defs/B"}, "B": {"type": "string"}}},
)
)
assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore
def test_unreferenced_invalid_def_is_ignored(self):
model = json_response_schema_to_pydantic(
_object(
{"name": {"type": "string"}},
**{"$defs": {"Broken": {"allOf": [{"type": "string"}]}}},
)
)
assert model.model_validate({"name": "x"}).name == "x" # pyright: ignore
@pytest.mark.parametrize(
"ref",
["#", "#/x", "#/$defs/a/b", "#/properties/a", "https://x.dev/s.json#/$defs/X"],
)
def test_unsupported_ref_forms(self, ref: str):
with pytest.raises(ValueError, match=r"unsupported \$ref"):
json_response_schema_to_pydantic(
_object(
{"a": {"$ref": ref}},
**{"$defs": {"a": {"type": "string"}}},
)
)
def test_unknown_definition(self):
with pytest.raises(ValueError, match="unknown definition"):
json_response_schema_to_pydantic(
_object({"a": {"$ref": "#/$defs/Missing"}}, **{"$defs": {}})
)
def test_direct_recursion_rejected(self):
with pytest.raises(ValueError, match=r"recursive \$ref.*cycle: Node -> Node"):
json_response_schema_to_pydantic(
_object(
{"tree": {"$ref": "#/$defs/Node"}},
**{
"$defs": {
"Node": _object(
{
"children": {
"type": "array",
"items": {"$ref": "#/$defs/Node"},
}
}
)
}
},
)
)
def test_mutual_recursion_rejected(self):
with pytest.raises(ValueError, match=r"cycle: A -> B -> A"):
json_response_schema_to_pydantic(
_object(
{"a": {"$ref": "#/$defs/A"}},
**{
"$defs": {
"A": _object({"b": {"$ref": "#/$defs/B"}}),
"B": _object({"a": {"$ref": "#/$defs/A"}}),
}
},
)
)
def test_recursive_pydantic_model_rejected(self):
class Node(BaseModel):
value: str
children: list["Node"] = []
with pytest.raises(ValueError, match=r"recursive \$ref"):
json_response_schema_to_pydantic(Node.model_json_schema())
def test_ref_expansion_counts_against_node_budget(self):
"""A doubling ref chain (billion laughs) is stopped by max_nodes."""
defs = {
f"L{i}": _object(
{
"a": {"$ref": f"#/$defs/L{i + 1}"},
"b": {"$ref": f"#/$defs/L{i + 1}"},
}
)
for i in range(10)
}
defs["L10"] = {"type": "string"}
with pytest.raises(ValueError, match="maximum of .* nodes"):
json_response_schema_to_pydantic(
_object({"root": {"$ref": "#/$defs/L0"}}, **{"$defs": defs})
)
def test_duplicate_name_across_defs_and_definitions(self):
with pytest.raises(ValueError, match="appears in both"):
json_response_schema_to_pydantic(
_object(
{"a": {"$ref": "#/$defs/X"}},
**{
"$defs": {"X": {"type": "string"}},
"definitions": {"X": {"type": "integer"}},
},
)
)
def test_root_must_be_object(self):
with pytest.raises(ValueError, match="root schema"):
json_response_schema_to_pydantic({"type": "string"})
def test_root_must_be_dict(self):
with pytest.raises(ValueError, match="JSON Schema object"):
json_response_schema_to_pydantic(["not", "a", "schema"]) # pyright: ignore
def test_no_recognizable_type(self):
with pytest.raises(ValueError, match="no recognizable type"):
json_response_schema_to_pydantic(_object({"mystery": {}}))
def test_depth_limit(self):
schema: dict[str, Any] = {"type": "string"}
for _ in range(25):
schema = _object({"inner": schema})
with pytest.raises(ValueError, match="maximum depth"):
json_response_schema_to_pydantic(schema)
def test_node_limit(self):
schema = _object({f"field_{i}": {"type": "string"} for i in range(600)})
with pytest.raises(ValueError, match="maximum of 500 nodes"):
json_response_schema_to_pydantic(schema)
def test_property_schema_not_an_object(self):
with pytest.raises(ValueError, match="schema must be an object"):
json_response_schema_to_pydantic(_object({"a": "string"}))
@pytest.mark.parametrize("members", [[], "not-a-list"])
def test_malformed_anyof(self, members: Any):
with pytest.raises(ValueError, match="'anyOf' must be a non-empty array"):
json_response_schema_to_pydantic(_object({"a": {"anyOf": members}}))
def test_empty_type_list(self):
with pytest.raises(ValueError, match="'type' array must not be empty"):
json_response_schema_to_pydantic(_object({"a": {"type": []}}))
@pytest.mark.parametrize("values", [[], "loves"])
def test_malformed_enum(self, values: Any):
with pytest.raises(ValueError, match="'enum' must be a non-empty array"):
json_response_schema_to_pydantic(_object({"a": {"enum": values}}))
def test_properties_not_an_object(self):
with pytest.raises(ValueError, match="'properties' must be an object"):
json_response_schema_to_pydantic({"type": "object", "properties": []})
@pytest.mark.parametrize("required", ["a", [1]])
def test_malformed_required(self, required: Any):
with pytest.raises(ValueError, match="'required' must be an array of strings"):
json_response_schema_to_pydantic(
_object({"a": {"type": "string"}}, required=required)
)
def test_empty_property_name(self):
with pytest.raises(ValueError, match="property names"):
json_response_schema_to_pydantic(_object({"": {"type": "string"}}))
class TestLenientAcceptance:
def test_additional_properties_false_ignored(self):
model = json_response_schema_to_pydantic(
_object(
{"known": {"type": "string"}},
required=["known"],
additionalProperties=False,
)
)
instance = model.model_validate({"known": "x", "extra": "dropped"})
assert instance.model_dump() == {"known": "x"}
def test_root_dollar_schema_ignored(self):
model = json_response_schema_to_pydantic(
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {"a": {"type": "string"}},
}
)
assert issubclass(model, BaseModel)
def test_empty_properties(self):
model = json_response_schema_to_pydantic({"type": "object", "properties": {}})
assert model.model_validate({}).model_dump() == {}
def test_missing_properties_with_object_type(self):
model = json_response_schema_to_pydantic({"type": "object"})
assert model.model_validate({"anything": 1}).model_dump() == {}
def test_missing_type_with_properties_treated_as_object(self):
model = json_response_schema_to_pydantic(
{"properties": {"a": {"type": "string"}}, "required": ["a"]}
)
assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore
def test_required_naming_unknown_property_ignored(self):
model = json_response_schema_to_pydantic(
_object({"a": {"type": "string"}}, required=["a", "ghost"])
)
assert model.model_validate({"a": "x"}).a == "x" # pyright: ignore
class TestFieldMetadata:
def test_description_propagates(self):
model = json_response_schema_to_pydantic(
_object({"food": {"type": "string", "description": "A food item"}})
)
generated = model.model_json_schema()
assert generated["properties"]["food"]["description"] == "A food item"
def test_constraint_hints_pass_through_unenforced(self):
model = json_response_schema_to_pydantic(
_object(
{
"tags": {
"type": "array",
"items": {"type": "string"},
"maxItems": 3,
}
},
required=["tags"],
)
)
generated = model.model_json_schema()
assert generated["properties"]["tags"]["maxItems"] == 3
# Not enforced: more than maxItems still validates.
instance = model.model_validate({"tags": ["a", "b", "c", "d"]})
assert len(instance.tags) == 4 # pyright: ignore
def test_non_identifier_key_alias_round_trip(self):
model = json_response_schema_to_pydantic(
_object(
{"my-key": {"type": "string"}, "_private": {"type": "integer"}},
required=["my-key"],
)
)
instance = model.model_validate({"my-key": "v", "_private": 7})
dumped = instance.model_dump_json(by_alias=True)
assert '"my-key":"v"' in dumped
assert '"_private":7' in dumped
def test_digit_leading_key_gets_field_prefix(self):
model = json_response_schema_to_pydantic(
_object({"123": {"type": "integer"}}, required=["123"])
)
instance = model.model_validate({"123": 7})
assert instance.model_dump(by_alias=True) == {"123": 7}
def test_sanitized_key_collision_round_trip(self):
# "my-key" sanitizes to "my_key", which then collides with the real
# "my_key" property; both must survive with their original JSON keys.
model = json_response_schema_to_pydantic(
_object(
{"my-key": {"type": "string"}, "my_key": {"type": "integer"}},
required=["my-key", "my_key"],
)
)
instance = model.model_validate({"my-key": "v", "my_key": 7})
assert instance.model_dump(by_alias=True) == {"my-key": "v", "my_key": 7}
def test_digit_leading_model_name(self):
model = json_response_schema_to_pydantic(
_object({"a": {"type": "string"}}), model_name="123"
)
assert model.__name__ == "Model123"
class TestZodCompatibility:
def test_zod4_tojsonschema_output_converts(self):
# Captured shape of zod 4's z.toJSONSchema() for
# z.object({ preferences: z.array(z.object({ food: z.string(),
# sentiment: z.enum(["loves","hates"]) })), summary: z.string(),
# note: z.string().optional() })
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"preferences": {
"type": "array",
"items": {
"type": "object",
"properties": {
"food": {"type": "string"},
"sentiment": {
"type": "string",
"enum": ["loves", "hates"],
},
},
"required": ["food", "sentiment"],
"additionalProperties": False,
},
},
"summary": {"type": "string"},
"note": {"type": "string"},
},
"required": ["preferences", "summary"],
"additionalProperties": False,
}
model = json_response_schema_to_pydantic(schema)
instance = model.model_validate(
{
"preferences": [{"food": "sushi", "sentiment": "loves"}],
"summary": "likes sushi",
}
)
assert instance.preferences[0].sentiment == "loves" # pyright: ignore
assert instance.note is None # pyright: ignore
class TestCustomGuardLimits:
def test_custom_max_depth(self):
schema: dict[str, Any] = {"type": "string"}
for _ in range(5):
schema = _object({"inner": schema})
with pytest.raises(ValueError, match="maximum depth of 3"):
json_response_schema_to_pydantic(schema, max_depth=3)
def test_custom_max_nodes(self):
schema = _object({f"f{i}": {"type": "string"} for i in range(20)})
with pytest.raises(ValueError, match="maximum of 10 nodes"):
json_response_schema_to_pydantic(schema, max_nodes=10)
def test_depth_exactly_at_limit_allowed(self):
# Leaf sits at depth == max_depth; only depth > max_depth must fail.
schema: dict[str, Any] = {"type": "string"}
for _ in range(3):
schema = _object({"inner": schema})
model = json_response_schema_to_pydantic(schema, max_depth=3)
assert issubclass(model, BaseModel)
# The wiki spec's own request example (dialectic-enhancements §3.A.1).
SPEC_EXAMPLE_SCHEMA = _object(
{
"preferences": {
"type": "array",
"items": _object(
{
"food": {"type": "string"},
"sentiment": {
"type": "string",
"enum": ["loves", "likes", "neutral", "dislikes", "hates"],
},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
},
required=["food", "sentiment"],
),
"maxItems": 3,
},
"summary": {"type": "string"},
},
required=["preferences", "summary"],
)
class TestEndToEnd:
"""Table tests running the full pipeline the server runs: convert the
caller's schema, validate a payload against the generated model, and
serialize it back with model_dump_json(by_alias=True)."""
@pytest.mark.parametrize(
"schema,payload,expected",
[
pytest.param(
SPEC_EXAMPLE_SCHEMA,
{
"preferences": [
{
"food": "dark roast coffee",
"sentiment": "loves",
"confidence": 0.95,
},
{"food": "sushi", "sentiment": "likes"},
],
"summary": "Coffee enthusiast.",
},
{
"preferences": [
{
"food": "dark roast coffee",
"sentiment": "loves",
"confidence": 0.95,
},
{"food": "sushi", "sentiment": "likes", "confidence": None},
],
"summary": "Coffee enthusiast.",
},
id="spec-example",
),
pytest.param(
_object(
{
"user": _object(
{
"name": {"type": "string"},
"location": _object(
{
"lat": {"type": "number"},
"lon": {"type": "number"},
},
required=["lat", "lon"],
),
},
required=["name", "location"],
)
},
required=["user"],
),
{"user": {"name": "ada", "location": {"lat": 37.8, "lon": -122.3}}},
{"user": {"name": "ada", "location": {"lat": 37.8, "lon": -122.3}}},
id="nested-three-levels",
),
pytest.param(
_object(
{"my-key": {"type": "string"}, "first name": {"type": "string"}},
required=["my-key"],
),
{"my-key": "v", "first name": "Ada"},
{"my-key": "v", "first name": "Ada"},
id="alias-keys-round-trip",
),
pytest.param(
_object(
{
"count": {"type": "integer", "default": 3},
"tag": {"type": "string", "default": "none"},
}
),
{},
{"count": 3, "tag": "none"},
id="defaults-fill-omitted-fields",
),
pytest.param(
_object(
{
"a": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"b": {"type": ["integer", "null"]},
},
required=["a", "b"],
),
{"a": None, "b": 2},
{"a": None, "b": 2},
id="nullable-via-anyof-and-type-list",
),
pytest.param(
_object(
{"value": {"oneOf": [{"type": "integer"}, {"type": "string"}]}},
required=["value"],
),
{"value": "five"},
{"value": "five"},
id="oneof-union-string-member",
),
pytest.param(
_object({"level": {"enum": [1, 2, None]}}, required=["level"]),
{"level": None},
{"level": None},
id="enum-with-null-member",
),
pytest.param(
_object({"stuff": {"type": "array"}}, required=["stuff"]),
{"stuff": [1, "two", {"three": 3}, None]},
{"stuff": [1, "two", {"three": 3}, None]},
id="array-without-items-accepts-anything",
),
pytest.param(
_object(
{
"tags": {
"type": "array",
"items": {"type": "string"},
"maxItems": 2,
}
},
required=["tags"],
),
{"tags": ["a", "b", "c", "d"]},
{"tags": ["a", "b", "c", "d"]},
id="constraint-hints-not-enforced",
),
pytest.param(
_object({"known": {"type": "string"}}, required=["known"]),
{"known": "x", "hallucinated": "dropped"},
{"known": "x"},
id="extra-keys-dropped",
),
pytest.param(
{"type": "object", "properties": {}},
{},
{},
id="empty-object",
),
],
)
def test_construct_validate_serialize(
self,
schema: dict[str, Any],
payload: dict[str, Any],
expected: dict[str, Any],
):
model = json_response_schema_to_pydantic(schema)
instance = model.model_validate(payload)
# by_alias=True mirrors DialecticAgent.answer's serialization.
assert json.loads(instance.model_dump_json(by_alias=True)) == expected
@pytest.mark.parametrize(
"schema,payload",
[
pytest.param(
SPEC_EXAMPLE_SCHEMA,
{"preferences": [{"food": "sushi"}], "summary": "s"},
id="missing-required-in-array-item",
),
pytest.param(
SPEC_EXAMPLE_SCHEMA,
{
"preferences": [{"food": "sushi", "sentiment": "adores"}],
"summary": "s",
},
id="invalid-enum-value",
),
pytest.param(
SPEC_EXAMPLE_SCHEMA,
{"preferences": [{"food": "sushi", "sentiment": "likes"}]},
id="missing-required-top-level",
),
pytest.param(
_object(
{"user": _object({"name": {"type": "string"}}, required=["name"])},
required=["user"],
),
{"user": {}},
id="missing-required-nested",
),
pytest.param(
_object({"a": {"type": "string"}}, required=["a"]),
{"a": None},
id="null-for-non-nullable",
),
pytest.param(
_object({"n": {"type": "integer"}}, required=["n"]),
{"n": {"nested": "dict"}},
id="wrong-type-for-integer",
),
],
)
def test_rejects_nonconforming_payloads(
self, schema: dict[str, Any], payload: dict[str, Any]
):
model = json_response_schema_to_pydantic(schema)
with pytest.raises(ValidationError):
model.model_validate(payload)