ai-response-validator / tests /unit /test_client.py
mbochniak01
Add typed client library, unit + integration tests, mypy, ruff, NOTES.md
10aced5
"""
Unit tests for ValidatorClient — all HTTP calls are mocked.
Tests cover: error mapping, retry behavior, response parsing, timeout handling.
"""
import httpx
import pytest
from pytest_mock import MockerFixture
from unittest.mock import MagicMock, patch
from client.client import ValidatorClient
from client.exceptions import APIError, RetryExhaustedError, TimeoutError
@pytest.fixture
def client() -> ValidatorClient:
return ValidatorClient(base_url="http://test.local", max_retries=2)
def _mock_response(status_code: int, json_body: object) -> MagicMock:
response = MagicMock(spec=httpx.Response)
response.status_code = status_code
response.is_error = status_code >= 400
response.json.return_value = json_body
response.text = str(json_body)
return response
class TestGetConfig:
def test_valid_response_parsed_to_model(self, client: ValidatorClient, mocker: MockerFixture) -> None:
mocker.patch.object(client._client, "request", return_value=_mock_response(200, {
"domains": {
"retail": [{"id": "novamart", "display": "NovaMart"}]
}
}))
config = client.get_config()
assert "retail" in config.domains
assert config.domains["retail"][0].id == "novamart"
def test_server_error_raises_api_error(self, client: ValidatorClient, mocker: MockerFixture) -> None:
mocker.patch.object(client._client, "request", return_value=_mock_response(500, {"detail": "boom"}))
with pytest.raises((APIError, RetryExhaustedError)):
client.get_config()
class TestQuery:
def test_valid_query_returns_response(self, client: ValidatorClient, mocker: MockerFixture) -> None:
mocker.patch.object(client._client, "request", return_value=_mock_response(200, {
"query": "test question",
"client": "novamart",
"client_display": "NovaMart",
"answer": "Here is the answer.",
"sources": [{"id": "retail_001", "title": "Stock Check", "score": 0.9}],
"evaluation": {
"overall_pass": True,
"metrics": {
"pii_leakage": {"passed": True, "score": 1.0, "detail": "Clean"},
}
}
}))
response = client.query("test question", "novamart")
assert response.answer == "Here is the answer."
assert response.evaluation.overall_pass is True
def test_unknown_client_raises_api_error(self, client: ValidatorClient, mocker: MockerFixture) -> None:
mocker.patch.object(client._client, "request", return_value=_mock_response(400, {
"detail": "Unknown client: 'bogus'"
}))
with pytest.raises(APIError) as exc_info:
client.query("question", "bogus")
assert exc_info.value.status_code == 400
def test_empty_query_raises_api_error(self, client: ValidatorClient, mocker: MockerFixture) -> None:
mocker.patch.object(client._client, "request", return_value=_mock_response(400, {
"detail": "Query cannot be empty"
}))
with pytest.raises(APIError) as exc_info:
client.query(" ", "novamart")
assert exc_info.value.status_code == 400
class TestRetryBehavior:
def test_retries_on_503_then_succeeds(self, client: ValidatorClient, mocker: MockerFixture) -> None:
call_count = 0
def side_effect(*args: object, **kwargs: object) -> MagicMock:
nonlocal call_count
call_count += 1
if call_count < 2:
return _mock_response(503, {"detail": "unavailable"})
return _mock_response(200, {"domains": {}})
mocker.patch.object(client._client, "request", side_effect=side_effect)
client.get_config()
assert call_count == 2
def test_exhausted_retries_raises_retry_exhausted(self, client: ValidatorClient, mocker: MockerFixture) -> None:
mocker.patch.object(client._client, "request", return_value=_mock_response(503, {}))
with pytest.raises(RetryExhaustedError) as exc_info:
client.get_config()
assert exc_info.value.attempts == client._max_retries
class TestTimeoutHandling:
def test_timeout_raises_timeout_error(self, client: ValidatorClient, mocker: MockerFixture) -> None:
mocker.patch.object(
client._client, "request",
side_effect=httpx.TimeoutException("timed out")
)
with pytest.raises(TimeoutError):
client.health()
class TestContextManager:
def test_client_closes_on_exit(self) -> None:
with patch("httpx.Client.close") as mock_close:
with ValidatorClient(base_url="http://test.local"):
pass
mock_close.assert_called_once()