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