File size: 4,799 Bytes
10aced5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | """
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()
|