"""Unit tests for Prometheus API client service.""" from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from src.services.prometheus import ( MAX_DATA_POINTS_PER_SERIES, MAX_TIME_SERIES, PrometheusClient, ) from src.utils.errors import ( AuthorizationError, NetworkError, PrometheusAPIError, PrometheusTimeoutError, ) def _mock_get_client( response: httpx.Response | None = None, get_side_effect: Exception | None = None, ): """Return a mock _get_client: yields client that returns response or raises get_side_effect.""" mock_client = MagicMock() if get_side_effect is not None: mock_client.get = AsyncMock(side_effect=get_side_effect) else: mock_client.get = AsyncMock(return_value=response) mock_cm = MagicMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_client) mock_cm.__aexit__ = AsyncMock(return_value=None) return mock_cm class TestPrometheusClientInit: """PrometheusClient stores base_url, timeout, and optional token.""" def test_stores_base_url_and_default_timeout(self): client = PrometheusClient("https://prom.example.com") assert client._base_url == "https://prom.example.com" assert client.timeout == 20.0 assert client._token is None def test_stores_custom_timeout_and_token(self): client = PrometheusClient("https://p.example.com", timeout=30.0, token="secret") assert client.timeout == 30.0 assert client._token == "secret" class TestRequest: """_request performs GET and maps HTTP/API errors to domain errors.""" @pytest.mark.asyncio async def test_returns_json_on_success(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": {"result": []}}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client._request( "/api/v1/query", {"query": "up"}, 10.0, "timeout", "api err" ) assert data["status"] == "success" assert data["data"]["result"] == [] @pytest.mark.asyncio async def test_401_raises_authorization_error(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(401, json={}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): with pytest.raises(AuthorizationError) as exc_info: await client._request("/x", {}, 10.0, "t", "e") assert "Unauthorized" in exc_info.value.message @pytest.mark.asyncio async def test_status_error_raises_prometheus_api_error(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response( 200, json={"status": "error", "errorType": "bad_data", "error": "parse error"}, ) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): with pytest.raises(PrometheusAPIError) as exc_info: await client._request("/x", {}, 10.0, "t", "Invalid query") assert "Invalid query" in exc_info.value.message assert "bad_data" in exc_info.value.prometheus_error assert "parse error" in exc_info.value.prometheus_error @pytest.mark.asyncio async def test_timeout_exception_raises_prometheus_timeout_error(self): client = PrometheusClient("https://prom.example.com") mock_cm = _mock_get_client(get_side_effect=httpx.TimeoutException("timed out")) with patch.object(client, "_get_client", return_value=mock_cm): with pytest.raises(PrometheusTimeoutError) as exc_info: await client._request("/x", {}, 10.0, "Query timed out", "e") assert ( "Query timed out" in exc_info.value.message or "timeout" in exc_info.value.message.lower() ) @pytest.mark.asyncio async def test_network_error_raises_network_error(self): client = PrometheusClient("https://prom.example.com") mock_cm = _mock_get_client(get_side_effect=httpx.ConnectError("unreachable")) with patch.object(client, "_get_client", return_value=mock_cm): with pytest.raises(NetworkError) as exc_info: await client._request("/x", {}, 10.0, "t", "e") assert "Prometheus" in exc_info.value.message or "unreachable" in exc_info.value.message class TestQueryRange: """query_range calls query_range API and applies truncation limits.""" @pytest.mark.asyncio async def test_success_empty_result(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response( 200, json={"status": "success", "data": {"result": []}}, ) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client.query_range( "up", "2024-01-01T00:00:00Z", "2024-01-01T01:00:00Z", "15s" ) assert data["data"]["result"] == [] assert data["status"] == "success" @pytest.mark.asyncio async def test_truncates_to_max_time_series_and_adds_warning(self): client = PrometheusClient("https://prom.example.com") # 2 * MAX_TIME_SERIES so we truncate many = [ {"metric": {"__name__": f"m{i}"}, "values": [[1, "1"]]} for i in range(MAX_TIME_SERIES + 10) ] resp = httpx.Response(200, json={"status": "success", "data": {"result": many}}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client.query_range( "up", "2024-01-01T00:00:00Z", "2024-01-01T01:00:00Z", "15s" ) assert len(data["data"]["result"]) == MAX_TIME_SERIES assert "warnings" in data["data"] assert any("truncated" in w or str(MAX_TIME_SERIES) in w for w in data["data"]["warnings"]) @pytest.mark.asyncio async def test_truncates_data_points_per_series_and_adds_warning(self): client = PrometheusClient("https://prom.example.com") # One series with more than MAX_DATA_POINTS_PER_SERIES points values = [[i, str(i)] for i in range(MAX_DATA_POINTS_PER_SERIES + 100)] result = [{"metric": {"__name__": "m1"}, "values": values}] resp = httpx.Response(200, json={"status": "success", "data": {"result": result}}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client.query_range( "up", "2024-01-01T00:00:00Z", "2024-01-01T01:00:00Z", "1s" ) assert len(data["data"]["result"]) == 1 assert len(data["data"]["result"][0]["values"]) == MAX_DATA_POINTS_PER_SERIES assert data["data"]["result"][0].get("truncated") is True assert "warnings" in data["data"] class TestQueryInstant: """query_instant calls instant query API and applies truncation.""" @pytest.mark.asyncio async def test_success_without_time_param(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response( 200, json={"status": "success", "data": {"result": [{"metric": {}, "value": [1, "1"]}]}}, ) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client.query_instant("up") assert len(data["data"]["result"]) == 1 assert data["data"]["result"][0]["value"] == [1, "1"] @pytest.mark.asyncio async def test_truncates_to_max_time_series(self): client = PrometheusClient("https://prom.example.com") many = [ {"metric": {"__name__": f"m{i}"}, "value": [1, "1"]} for i in range(MAX_TIME_SERIES + 5) ] resp = httpx.Response(200, json={"status": "success", "data": {"result": many}}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client.query_instant("up") assert len(data["data"]["result"]) == MAX_TIME_SERIES assert "warnings" in data["data"] class TestListMetricNames: """list_metric_names returns data list from label values API.""" @pytest.mark.asyncio async def test_returns_metric_names(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": ["metric_a", "metric_b"]}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): names = await client.list_metric_names() assert names == ["metric_a", "metric_b"] @pytest.mark.asyncio async def test_returns_empty_list_when_no_data(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success"}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): names = await client.list_metric_names() assert names == [] @pytest.mark.asyncio async def test_uses_discovery_timeout_by_default(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)) as mock_get: await client.list_metric_names() # Default timeout for list_metric_names is DISCOVERY_TIMEOUT call_kw = mock_get.return_value.__aenter__.return_value.get.call_args assert call_kw is not None @pytest.mark.asyncio async def test_passes_limit_param(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_metric_names(limit=123) call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["limit"] == 123 @pytest.mark.asyncio async def test_passes_match_param(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_metric_names(match='{__name__=~"topsql_.*"}') call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["match[]"] == '{__name__=~"topsql_.*"}' @pytest.mark.asyncio async def test_passes_start_end_params(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_metric_names( start="2024-01-01T00:00:00Z", end="2024-01-01T01:00:00Z", ) call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["start"] == "2024-01-01T00:00:00Z" assert call_args[1]["params"]["end"] == "2024-01-01T01:00:00Z" class TestGetMetadata: """get_metadata returns dict from /api/v1/metadata.""" @pytest.mark.asyncio async def test_returns_metadata_dict(self): client = PrometheusClient("https://prom.example.com") metadata = { "http_requests_total": [{"type": "counter", "help": "Total requests", "unit": ""}], } resp = httpx.Response(200, json={"status": "success", "data": metadata}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): result = await client.get_metadata() assert result == metadata @pytest.mark.asyncio async def test_passes_metric_param(self): client = PrometheusClient("https://prom.example.com") metadata = { "up": [{"type": "gauge", "help": "Target is up", "unit": ""}], } resp = httpx.Response(200, json={"status": "success", "data": metadata}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): result = await client.get_metadata(metric="up") assert result == metadata call_args = mock_client.__aenter__.return_value.get.call_args assert call_args is not None assert call_args[1]["params"]["metric"] == "up" @pytest.mark.asyncio async def test_returns_empty_dict_when_no_data(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success"}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): result = await client.get_metadata() assert result == {} @pytest.mark.asyncio async def test_passes_limit_param(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": {}}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.get_metadata(limit=10) call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["limit"] == 10 class TestListLabels: @pytest.mark.asyncio async def test_returns_labels(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": ["job", "instance"]}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): labels = await client.list_labels() assert labels == ["job", "instance"] @pytest.mark.asyncio async def test_passes_match_param(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_labels(match='{__name__=~"topsql_.*"}') call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["match[]"] == '{__name__=~"topsql_.*"}' @pytest.mark.asyncio async def test_passes_start_end_params(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_labels(start="2024-01-01T00:00:00Z", end="2024-01-01T01:00:00Z") call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["start"] == "2024-01-01T00:00:00Z" assert call_args[1]["params"]["end"] == "2024-01-01T01:00:00Z" @pytest.mark.asyncio async def test_passes_limit_param(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_labels(limit=50) call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["limit"] == 50 class TestListLabelValues: @pytest.mark.asyncio async def test_returns_label_values(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": ["tidb-0", "tidb-1"]}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): values = await client.list_label_values("instance") assert values == ["tidb-0", "tidb-1"] @pytest.mark.asyncio async def test_passes_match_param(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_label_values("job", match='{job="tidb"}') call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[0][0] == "/api/v1/label/job/values" assert call_args[1]["params"]["match[]"] == '{job="tidb"}' @pytest.mark.asyncio async def test_passes_start_end_params(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_label_values( "job", start="2024-01-01T00:00:00Z", end="2024-01-01T01:00:00Z", ) call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["start"] == "2024-01-01T00:00:00Z" assert call_args[1]["params"]["end"] == "2024-01-01T01:00:00Z" @pytest.mark.asyncio async def test_passes_limit_param(self): client = PrometheusClient("https://prom.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_client = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_client): await client.list_label_values("job", limit=75) call_args = mock_client.__aenter__.return_value.get.call_args assert call_args[1]["params"]["limit"] == 75