Files
kleinanzeigen-bot/tests/unit/test_pydantics.py

301 lines
12 KiB
Python

# SPDX-FileCopyrightText: © Jens Bergmann and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
"""Tests for the pydantics utilities module.
Covers ContextualValidationError, ContextualModel, and format_validation_error.
"""
from typing import Any, TypedDict, cast
import pytest
from pydantic import BaseModel, ValidationError
from pydantic_core import ErrorDetails as PydanticErrorDetails
from typing_extensions import NotRequired
from kleinanzeigen_bot.utils.pydantics import (
ContextualModel,
ContextualValidationError,
format_validation_error,
)
class ErrorDetails(TypedDict):
loc:tuple[str, ...]
msg:str
type:str
input:NotRequired[Any]
ctx:NotRequired[dict[str, Any]]
# --------------------------------------------------------------------------- #
# Test fixtures
# --------------------------------------------------------------------------- #
@pytest.fixture
def context() -> dict[str, Any]:
"""Fixture for a sample context."""
return {"user": "test", "reason": "unit-test"}
# --------------------------------------------------------------------------- #
# Test cases
# --------------------------------------------------------------------------- #
class TestContextualValidationError:
"""Test ContextualValidationError behavior."""
def test_context_attached(self, context:dict[str, Any]) -> None:
"""Context is attached to the exception."""
ex = ContextualValidationError("test", [])
ex.context = context
assert ex.context == context
def test_context_missing(self) -> None:
"""Context is missing (default)."""
ex = ContextualValidationError("test", [])
assert not hasattr(ex, "context") or ex.context is None
class TestContextualModel:
"""Test ContextualModel validation logic."""
class SimpleModel(ContextualModel): # type: ignore[unused-ignore,misc]
x:int
def test_model_validate_success(self) -> None:
"""Valid input returns a model instance."""
result = self.SimpleModel.model_validate({"x": 42})
assert isinstance(result, self.SimpleModel)
assert result.x == 42
def test_model_validate_failure_with_context(self, context:dict[str, Any]) -> None:
"""Invalid input raises ContextualValidationError with context."""
with pytest.raises(ContextualValidationError) as exc_info:
self.SimpleModel.model_validate({"x": "not-an-int"}, context = context)
assert exc_info.value.context == context
class TestFormatValidationError:
"""Test format_validation_error output."""
class SimpleModel(BaseModel):
y:int
@pytest.mark.parametrize(
("error_details", "expected"),
[
# Standard error with known code and context
(
[{"loc": ("foo",), "msg": "dummy", "type": "int_parsing", "ctx": {}}],
"Input should be a valid integer, unable to parse string as an integer",
),
# Error with context variable
(
[{"loc": ("bar",), "msg": "dummy", "type": "greater_than", "ctx": {"gt": 5}}],
"greater than 5",
),
# Error with unknown code
(
[{"loc": ("baz",), "msg": "dummy", "type": "unknown_code"}],
"[type=unknown_code]",
),
# Error with message template containing ' or '
(
[{"loc": ("qux",), "msg": "dummy", "type": "enum", "ctx": {"expected": "'a' or 'b'"}}],
"' or '",
),
# Error with no context
(
[{"loc": ("nocontext",), "msg": "dummy", "type": "string_type"}],
"Input should be a valid string",
),
# Date/time related errors
(
[{"loc": ("date",), "msg": "dummy", "type": "date_parsing", "ctx": {"error": "invalid format"}}],
"Input should be a valid date in the format YYYY-MM-DD",
),
(
[{"loc": ("datetime",), "msg": "dummy", "type": "datetime_parsing", "ctx": {"error": "invalid format"}}],
"Input should be a valid datetime",
),
(
[{"loc": ("time",), "msg": "dummy", "type": "time_parsing", "ctx": {"error": "invalid format"}}],
"Input should be in a valid time format",
),
# URL related errors
(
[{"loc": ("url",), "msg": "dummy", "type": "url_parsing", "ctx": {"error": "invalid format"}}],
"Input should be a valid URL",
),
(
[{"loc": ("url_scheme",), "msg": "dummy", "type": "url_scheme", "ctx": {"expected_schemes": "http,https"}}],
"URL scheme should be http,https",
),
# UUID related errors
(
[{"loc": ("uuid",), "msg": "dummy", "type": "uuid_parsing", "ctx": {"error": "invalid format"}}],
"Input should be a valid UUID",
),
(
[{"loc": ("uuid_version",), "msg": "dummy", "type": "uuid_version", "ctx": {"expected_version": 4}}],
"UUID version 4 expected",
),
# Decimal related errors
(
[{"loc": ("decimal",), "msg": "dummy", "type": "decimal_parsing"}],
"Input should be a valid decimal",
),
(
[{"loc": ("decimal_max_digits",), "msg": "dummy", "type": "decimal_max_digits", "ctx": {"max_digits": 10, "expected_plural": "s"}}],
"Decimal input should have no more than 10 digits in total",
),
(
[{"loc": ("decimal_max_places",), "msg": "dummy", "type": "decimal_max_places", "ctx": {"decimal_places": 2, "expected_plural": "s"}}],
"Decimal input should have no more than 2 decimal places",
),
(
[{"loc": ("decimal_whole_digits",), "msg": "dummy", "type": "decimal_whole_digits", "ctx": {"whole_digits": 3, "expected_plural": ""}}],
"Decimal input should have no more than 3 digits before the decimal point",
),
# Complex number related errors
(
[{"loc": ("complex",), "msg": "dummy", "type": "complex_type"}],
"Input should be a valid python complex object",
),
(
[{"loc": ("complex_str",), "msg": "dummy", "type": "complex_str_parsing"}],
"Input should be a valid complex string",
),
# List/sequence related errors
(
[{"loc": ("list",), "msg": "dummy", "type": "list_type"}],
"Input should be a valid list",
),
(
[{"loc": ("tuple",), "msg": "dummy", "type": "tuple_type"}],
"Input should be a valid tuple",
),
(
[{"loc": ("set",), "msg": "dummy", "type": "set_type"}],
"Input should be a valid set",
),
# String related errors
(
[{"loc": ("string_pattern",), "msg": "dummy", "type": "string_pattern_mismatch", "ctx": {"pattern": r"\d+"}}],
"String should match pattern '\\d+'",
),
(
[{"loc": ("string_length",), "msg": "dummy", "type": "string_too_short", "ctx": {"min_length": 5, "expected_plural": "s"}}],
"String should have at least 5 characters",
),
# Number related errors
(
[{"loc": ("float",), "msg": "dummy", "type": "float_type"}],
"Input should be a valid number",
),
(
[{"loc": ("int",), "msg": "dummy", "type": "int_type"}],
"Input should be a valid integer",
),
# Boolean related errors
(
[{"loc": ("bool",), "msg": "dummy", "type": "bool_type"}],
"Input should be a valid boolean",
),
(
[{"loc": ("bool_parsing",), "msg": "dummy", "type": "bool_parsing"}],
"Input should be a valid boolean, unable to interpret input",
),
],
)
def test_various_error_codes(self, error_details:list[dict[str, Any]], expected:str) -> None:
"""Test various error codes and message formatting."""
class DummyValidationError(ValidationError):
def errors(self, *, include_url:bool = True, include_context:bool = True, include_input:bool = True) -> list[PydanticErrorDetails]:
return cast(list[PydanticErrorDetails], error_details)
def error_count(self) -> int:
return len(error_details)
@property
def title(self) -> str:
return "Dummy"
ex = DummyValidationError("dummy", [])
out = format_validation_error(ex)
assert any(exp in out for exp in expected.split()), f"Expected '{expected}' in output: {out}"
def test_format_standard_validation_error(self) -> None:
"""Standard ValidationError produces expected string."""
try:
self.SimpleModel(y = "not an int") # type: ignore[arg-type]
except ValidationError as ex:
out = format_validation_error(ex)
assert "validation error" in out
assert "y" in out
assert "integer" in out
def test_format_contextual_validation_error(self, context:dict[str, Any]) -> None:
"""ContextualValidationError includes context in output."""
class Model(ContextualModel): # type: ignore[unused-ignore,misc]
z:int
with pytest.raises(ContextualValidationError) as exc_info:
Model.model_validate({"z": "not an int"}, context = context)
assert exc_info.value.context == context
def test_format_unknown_error_code(self) -> None:
"""Unknown error code falls back to default formatting."""
class DummyValidationError(ValidationError):
def errors(self, *, include_url:bool = True, include_context:bool = True, include_input:bool = True) -> list[PydanticErrorDetails]:
return cast(list[PydanticErrorDetails], [{"loc": ("foo",), "msg": "dummy", "type": "unknown_code", "input": None}])
def error_count(self) -> int:
return 1
@property
def title(self) -> str:
return "Dummy"
ex = DummyValidationError("dummy", [])
out = format_validation_error(ex)
assert "foo" in out
assert "dummy" in out
assert "[type=unknown_code]" in out
def test_pluralization_and_empty_errors(self) -> None:
"""Test pluralization in header and empty error list edge case."""
class DummyValidationError(ValidationError):
def errors(self, *, include_url:bool = True, include_context:bool = True, include_input:bool = True) -> list[PydanticErrorDetails]:
return cast(list[PydanticErrorDetails], [
{"loc": ("a",), "msg": "dummy", "type": "int_type"},
{"loc": ("b",), "msg": "dummy", "type": "int_type"},
])
def error_count(self) -> int:
return 2
@property
def title(self) -> str:
return "Dummy"
ex1 = DummyValidationError("dummy", [])
out = format_validation_error(ex1)
assert "2 validation errors" in out
assert "a" in out
assert "b" in out
# Empty error list
class EmptyValidationError(ValidationError):
def errors(self, *, include_url:bool = True, include_context:bool = True, include_input:bool = True) -> list[PydanticErrorDetails]:
return cast(list[PydanticErrorDetails], [])
def error_count(self) -> int:
return 0
@property
def title(self) -> str:
return "Empty"
ex2 = EmptyValidationError("empty", [])
out = format_validation_error(ex2)
assert "0 validation errors" in out
assert out.count("-") == 0