mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
301 lines
12 KiB
Python
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
|