| """ |
| 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() |
|
|