Files
kleinanzeigen-bot/tests/unit/test_pydantics.py
Jens Bergmann 50656ad7e2 feat: Improve test coverage (#515)
* test: implement comprehensive test coverage improvements

This commit improves test coverage across multiple modules, adding unit tests
for core functionality.

Key improvements:

1. WebScrapingMixin:
   - Add comprehensive async error handling tests
   - Add session management tests (browser crash recovery, session expiration)
   - Add element interaction tests (custom wait conditions, timeouts)
   - Add browser configuration tests (extensions, preferences)
   - Add robust awaitable mocking infrastructure
   - Rename integration test file to avoid naming conflicts

2. Error Handlers:
   - Add tests for error message formatting
   - Add tests for error recovery scenarios
   - Add tests for error logging functionality

3. Network Utilities:
   - Add tests for port checking functionality
   - Add tests for network error handling
   - Add tests for connection management

4. Pydantic Models:
   - Add tests for validation cases
   - Add tests for error handling
   - Add tests for complex validation scenarios

Technical details:
- Use TrulyAwaitableMockPage for proper async testing
- Add comprehensive mocking for browser and page objects
- Add proper cleanup in session management tests
- Add browser-specific configuration tests (Chrome/Edge)
- Add proper type hints and docstrings

Files changed:
- Renamed: tests/integration/test_web_scraping_mixin.py → tests/integration/test_web_scraping_mixin_integration.py
- Added: tests/unit/test_error_handlers.py
- Added: tests/unit/test_net.py
- Added: tests/unit/test_pydantics.py
- Added: tests/unit/test_web_scraping_mixin.py

* test: enhance test coverage with additional edge cases and scenarios

This commit extends the test coverage improvements with additional test cases
and edge case handling, focusing on browser configuration, error handling, and
file utilities.

Key improvements:

1. WebScrapingMixin:
   - Add comprehensive browser binary location detection tests
   - Add cross-platform browser path detection (Linux, macOS, Windows)
   - Add browser profile configuration tests
   - Add session state persistence tests
   - Add external process termination handling
   - Add session creation error cleanup tests
   - Improve browser argument configuration tests
   - Add extension loading validation tests

2. Error Handlers:
   - Add debug mode error handling tests
   - Add specific error type tests (AttributeError, ImportError, NameError, TypeError)
   - Improve error message formatting tests
   - Add traceback inclusion verification

3. Pydantic Models:
   - Add comprehensive validation error message tests
   - Add tests for various error codes and contexts
   - Add tests for pluralization in error messages
   - Add tests for empty error list handling
   - Add tests for context handling in validation errors

4. File Utilities:
   - Add comprehensive path resolution tests
   - Add tests for file and directory reference handling
   - Add tests for special path cases
   - Add tests for nonexistent path handling
   - Add tests for absolute and relative path conversion

Technical details:
- Add proper type casting for test fixtures
- Improve test isolation and cleanup
- Add platform-specific browser path detection
- Add proper error context handling
- Add comprehensive error message formatting tests
- Add proper cleanup in session management tests
- Add browser-specific configuration tests
- Add proper path normalization and resolution tests

* fix(test): handle Linux browser paths in web_scraping_mixin test

Update mock_exists to properly detect Linux browser binaries in test_browser_profile_configuration, fixing the "Installed browser could not be detected" error.

* fix(test): handle Windows browser paths in web_scraping_mixin test

Add Windows browser paths to mock_exists function to properly detect browser binaries on Windows platform, fixing the "Specified browser binary does not exist" error.
2025-05-18 19:02:59 +02:00

293 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",
),
# 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