"""Unit tests for Loki API client service.""" from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from src.services.loki import ( DEFAULT_QUERY_TIMEOUT, MAX_LOG_ENTRIES, LokiClient, _convert_to_unix_timestamp, ) from src.utils.errors import ( AuthorizationError, LokiAPIError, LokiTimeoutError, NetworkError, ) 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 TestLokiClientInit: """LokiClient stores base_url, timeout, and optional token.""" def test_stores_base_url_and_default_timeout(self): client = LokiClient("https://loki.example.com") assert client._base_url == "https://loki.example.com" assert client.timeout == DEFAULT_QUERY_TIMEOUT assert client._token is None def test_stores_custom_timeout_and_token(self): client = LokiClient("https://loki.example.com", timeout=60.0, token="secret-token") assert client.timeout == 60.0 assert client._token == "secret-token" class TestTimestampConversion: """_convert_to_unix_timestamp handles RFC3339/ISO8601 with timezone offsets.""" @pytest.mark.parametrize( ("timestamp", "expected"), [ ("2024-01-01T00:00:00Z", "1704067200"), ("2024-01-01T00:00:00+08:00", "1704038400"), ("2024-01-01T00:00:00-05:30", "1704087000"), ("2024-01-01T00:00:00+0800", "1704038400"), ("2024-01-01T00:00:00+08", "1704038400"), ("2024-01-01T00:00:00", "1704067200"), ], ) def test_converts_iso8601_with_various_timezones(self, timestamp: str, expected: str): assert _convert_to_unix_timestamp(timestamp) == expected def test_returns_original_on_invalid_timestamp(self): invalid_ts = "not-a-timestamp" assert _convert_to_unix_timestamp(invalid_ts) == invalid_ts 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 = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client._request( "/loki/api/v1/query", {"query": "{app=\"test\"}"}, 10.0, "timeout", "api err" ) assert data["status"] == "success" assert data["data"] == [] @pytest.mark.asyncio async def test_401_raises_authorization_error(self): client = LokiClient("https://loki.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_loki_api_error(self): client = LokiClient("https://loki.example.com") resp = httpx.Response( 200, json={"status": "error", "errorType": "parse_error", "error": "invalid query"} ) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): with pytest.raises(LokiAPIError) as exc_info: await client._request("/x", {}, 10.0, "t", "Invalid log query") assert "Invalid log query" in exc_info.value.message assert "parse_error" in exc_info.value.loki_error assert "invalid query" in exc_info.value.loki_error @pytest.mark.asyncio async def test_timeout_exception_raises_loki_timeout_error(self): client = LokiClient("https://loki.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(LokiTimeoutError) 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 str( exc_info.value.message.lower() ) @pytest.mark.asyncio async def test_network_error_raises_network_error(self): client = LokiClient("https://loki.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 "Loki" in exc_info.value.message or "unreachable" in exc_info.value.message class TestQueryRange: """query_range calls Loki range query API and applies truncation.""" @pytest.mark.asyncio async def test_success_empty_result(self): client = LokiClient("https://loki.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( "{app=\"test\"}", "2024-01-01T00:00:00Z", "2024-01-01T01:00:00Z" ) assert data["data"]["result"] == [] assert data["status"] == "success" @pytest.mark.asyncio async def test_truncates_to_max_log_entries(self): client = LokiClient("https://loki.example.com") # Create more entries than MAX_LOG_ENTRIES many_entries = [ {"timestamp": i, "line": f"log line {i}"} for i in range(MAX_LOG_ENTRIES + 100) ] resp = httpx.Response(200, json={"status": "success", "data": {"result": many_entries}}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client.query_range( "{app=\"test\"}", "2024-01-01T00:00:00Z", "2024-01-01T01:00:00Z" ) assert len(data["data"]["result"]) == MAX_LOG_ENTRIES assert "warnings" in data["data"] assert any("truncated" in w or str(MAX_LOG_ENTRIES) in w for w in data["data"]["warnings"]) @pytest.mark.asyncio async def test_includes_direction_and_step_params(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success", "data": {"result": []}}) mock_cm = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_cm): await client.query_range( "{app=\"test\"}", "2024-01-01T00:00:00Z", "2024-01-01T01:00:00Z", limit=500, direction="forward", step="1m", ) # Verify params were passed correctly call_args = mock_cm.__aenter__.return_value.get.call_args assert call_args is not None params = call_args[1]["params"] assert params["direction"] == "forward" assert params["limit"] == 500 assert params["step"] == "1m" class TestQueryInstant: """query_instant calls Loki instant query API.""" @pytest.mark.asyncio async def test_success_with_time_param(self): client = LokiClient("https://loki.example.com") resp = httpx.Response( 200, json={ "status": "success", "data": {"result": [{"timestamp": "1", "line": "test log"}]}, }, ) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): data = await client.query_instant("{app=\"test\"}", time="2024-01-01T00:00:00Z") assert len(data["data"]["result"]) == 1 assert data["data"]["result"][0]["line"] == "test log" @pytest.mark.asyncio async def test_converts_time_param(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success", "data": {"result": []}}) mock_cm = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_cm): await client.query_instant("{app=\"test\"}", time="2024-01-01T00:00:00+08:00") call_args = mock_cm.__aenter__.return_value.get.call_args params = call_args[1]["params"] assert params["time"] == "1704038400" @pytest.mark.asyncio async def test_success_without_time_param(self): client = LokiClient("https://loki.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_instant("{app=\"test\"}") assert data["status"] == "success" @pytest.mark.asyncio async def test_uses_custom_timeout(self): client = LokiClient("https://loki.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_instant("{app=\"test\"}", timeout=20.0) assert data["status"] == "success" class TestListLabels: """list_labels calls Loki label discovery API.""" @pytest.mark.asyncio async def test_returns_label_names(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success", "data": ["app", "namespace", "pod"]}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): labels = await client.list_labels() assert labels == ["app", "namespace", "pod"] @pytest.mark.asyncio async def test_returns_empty_list_when_no_data(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success"}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): labels = await client.list_labels() assert labels == [] @pytest.mark.asyncio async def test_includes_time_range_params(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_cm = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_cm): await client.list_labels( start="2024-01-01T00:00:00Z", end="2024-01-01T01:00:00Z", query="{app=\"test\"}", ) call_args = mock_cm.__aenter__.return_value.get.call_args params = call_args[1]["params"] assert params["start"] == "1704067200" assert params["end"] == "1704070800" assert params["query"] == "{app=\"test\"}" class TestGetLabelValues: """get_label_values calls Loki label values API.""" @pytest.mark.asyncio async def test_returns_label_values(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success", "data": ["app1", "app2", "app3"]}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): values = await client.get_label_values("app") assert values == ["app1", "app2", "app3"] @pytest.mark.asyncio async def test_returns_empty_list_when_no_data(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success"}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): values = await client.get_label_values("app") assert values == [] @pytest.mark.asyncio async def test_includes_converted_time_range_params(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_cm = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_cm): await client.get_label_values( "app", start="2024-01-01T00:00:00Z", end="2024-01-01T01:00:00Z", query="{app=\"test\"}", ) call_args = mock_cm.__aenter__.return_value.get.call_args params = call_args[1]["params"] assert params["start"] == "1704067200" assert params["end"] == "1704070800" assert params["query"] == "{app=\"test\"}" class TestListSeries: """list_series calls Loki series metadata API.""" @pytest.mark.asyncio async def test_returns_series_list(self): client = LokiClient("https://loki.example.com") series_data = [ {"app": "test", "namespace": "default"}, {"app": "prod", "namespace": "prod"}, ] resp = httpx.Response(200, json={"status": "success", "data": series_data}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): series = await client.list_series(match='{"app"=~"te.*"}') assert series == series_data @pytest.mark.asyncio async def test_returns_empty_list_when_no_data(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success"}) with patch.object(client, "_get_client", return_value=_mock_get_client(resp)): series = await client.list_series('{"app"="nonexistent"}') assert series == [] @pytest.mark.asyncio async def test_includes_time_range_params(self): client = LokiClient("https://loki.example.com") resp = httpx.Response(200, json={"status": "success", "data": []}) mock_cm = _mock_get_client(resp) with patch.object(client, "_get_client", return_value=mock_cm): await client.list_series( '{"app"="test"}', start="2024-01-01T00:00:00Z", end="2024-01-01T01:00:00Z", ) call_args = mock_cm.__aenter__.return_value.get.call_args params = call_args[1]["params"] assert params["match"] == '{"app"="test"}' assert params["start"] == "1704067200" assert params["end"] == "1704070800"