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