206 lines
6.0 KiB
Python
206 lines
6.0 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
from jsonschema import Draft202012Validator
|
|
from pydantic import ValidationError
|
|
|
|
from wf_core import SchemaRef, WorkflowExecutionError
|
|
from wf_core.models.schemas import StateFieldDecl
|
|
from wf_core.runtime.ops.schemas import validate_payload_against_schema
|
|
|
|
|
|
def test_schema_validation_rejects_wrong_property_type() -> None:
|
|
schema = SchemaRef.model_validate(
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"count": {"type": "integer"},
|
|
},
|
|
"required": ["name", "count"],
|
|
}
|
|
)
|
|
|
|
with pytest.raises(WorkflowExecutionError, match=r"count.*not of type 'integer'"):
|
|
validate_payload_against_schema(
|
|
schema,
|
|
{"name": "ok", "count": "not-an-int"},
|
|
"node output for counter",
|
|
)
|
|
|
|
|
|
def test_schema_validation_rejects_nested_missing_required_field() -> None:
|
|
schema = SchemaRef.model_validate(
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"profile": {
|
|
"type": "object",
|
|
"properties": {"email": {"type": "string"}},
|
|
"required": ["email"],
|
|
}
|
|
},
|
|
"required": ["profile"],
|
|
}
|
|
)
|
|
|
|
with pytest.raises(WorkflowExecutionError, match=r"profile.*email.*required"):
|
|
validate_payload_against_schema(
|
|
schema,
|
|
{"profile": {}},
|
|
"workflow input",
|
|
)
|
|
|
|
|
|
def test_schema_validation_accepts_valid_payload() -> None:
|
|
schema = SchemaRef.model_validate(
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"tags": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": ["tags"],
|
|
}
|
|
)
|
|
|
|
validate_payload_against_schema(schema, {"tags": ["a", "b"]}, "node input")
|
|
|
|
|
|
def test_schema_ref_accepts_and_preserves_schema_with_defs_and_ref() -> None:
|
|
schema = SchemaRef.model_validate(
|
|
{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"$defs": {
|
|
"tag": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
"required": ["name"],
|
|
}
|
|
},
|
|
"type": "object",
|
|
"properties": {"tag": {"$ref": "#/$defs/tag"}},
|
|
"required": ["tag"],
|
|
}
|
|
)
|
|
|
|
dumped = schema.model_dump(mode="json")
|
|
|
|
assert dumped["$schema"] == "https://json-schema.org/draft/2020-12/schema"
|
|
assert dumped["$defs"]["tag"]["type"] == "object"
|
|
assert dumped["properties"]["tag"]["$ref"] == "#/$defs/tag"
|
|
assert dumped["required"] == ["tag"]
|
|
|
|
|
|
def test_schema_ref_rejects_invalid_json_schema_shape() -> None:
|
|
with pytest.raises(ValidationError, match="invalid JSON Schema"):
|
|
SchemaRef.model_validate({"type": 123})
|
|
|
|
|
|
def test_schema_ref_defaults_to_draft_2020_12_without_schema_keyword() -> None:
|
|
schema = SchemaRef.model_validate(
|
|
{
|
|
"type": "object",
|
|
"properties": {"count": {"type": "integer"}},
|
|
"required": ["count"],
|
|
}
|
|
)
|
|
|
|
dumped = schema.model_dump(mode="json")
|
|
|
|
assert "$schema" not in dumped
|
|
assert dumped["type"] == "object"
|
|
assert dumped["properties"]["count"]["type"] == "integer"
|
|
|
|
|
|
def test_schema_ref_preserves_extra_json_schema_keywords() -> None:
|
|
schema = SchemaRef.model_validate(
|
|
{
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
"additionalProperties": False,
|
|
}
|
|
)
|
|
|
|
dumped = schema.model_dump(mode="json")
|
|
|
|
assert dumped["type"] == "object"
|
|
assert dumped["additionalProperties"] is False
|
|
assert dumped["properties"]["name"]["type"] == "string"
|
|
|
|
|
|
def test_schema_ref_dump_omits_none_fields_and_stays_valid_json_schema() -> None:
|
|
dumped = SchemaRef(type="object").model_dump(mode="json")
|
|
|
|
assert "title" not in dumped
|
|
assert dumped["type"] == "object"
|
|
Draft202012Validator.check_schema(dumped)
|
|
|
|
|
|
def test_state_field_decl_dump_omits_nested_schema_none_fields() -> None:
|
|
field = StateFieldDecl.model_validate(
|
|
{
|
|
"path": "state.person",
|
|
"schema": {"type": "object"},
|
|
}
|
|
)
|
|
|
|
dumped = field.model_dump(mode="json")
|
|
|
|
assert dumped["schema"]["type"] == "object"
|
|
assert "title" not in dumped["schema"]
|
|
Draft202012Validator.check_schema(dumped["schema"])
|
|
|
|
|
|
def test_state_schema_dump_is_valid_json_schema_with_reducer_keyword() -> None:
|
|
from wf_core import StateSchema
|
|
|
|
schema = StateSchema.model_validate(
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"count": {
|
|
"type": "integer",
|
|
"description": "Running count",
|
|
"reducer": "wf.std.add",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
dumped = schema.model_dump(mode="json")
|
|
assert dumped["type"] == "object"
|
|
assert dumped["properties"]["count"]["description"] == "Running count"
|
|
assert dumped["properties"]["count"]["reducer"] == "wf.std.add"
|
|
Draft202012Validator.check_schema(dumped)
|
|
|
|
|
|
def test_state_field_validation_schema_preserves_root_defs_for_local_refs() -> None:
|
|
from wf_core import StateSchema
|
|
|
|
schema = StateSchema.model_validate(
|
|
{
|
|
"type": "object",
|
|
"$defs": {
|
|
"PoolByCategory": {
|
|
"type": "object",
|
|
"properties": {"category": {"type": "string"}},
|
|
"required": ["category"],
|
|
}
|
|
},
|
|
"properties": {
|
|
"current_pools": {
|
|
"type": "array",
|
|
"items": {"$ref": "#/$defs/PoolByCategory"},
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
field_schema = schema.field_map()["current_pools"].validation_schema
|
|
|
|
validate_payload_against_schema(
|
|
field_schema,
|
|
[{"category": "1"}],
|
|
"state write state.current_pools",
|
|
)
|