"""Unit tests for list_loki_series MCP tool.""" import json import sys from pathlib import Path from unittest.mock import AsyncMock, patch import pytest project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root)) from src.services.loki import LokiClient # noqa: E402 from src.utils.errors import ( # noqa: E402 AuthServiceError, LokiTimeoutError, NetworkError, ValidationError, ) def _import_list_loki_series_tool(): """Import list_loki_series tool function without triggering circular imports.""" import importlib.util tool_path = project_root / "src" / "tools" / "list_loki_series.py" spec = importlib.util.spec_from_file_location("list_loki_series_module", str(tool_path)) if spec is None or spec.loader is None: raise ImportError("Could not load list_loki_series module") module = importlib.util.module_from_spec(spec) sys.modules["list_loki_series_module"] = module spec.loader.exec_module(module) return module.list_loki_series class TestListLokiSeriesExecution: """Test list_loki_series tool execution.""" @pytest.mark.asyncio async def test_successful_list_returns_json_response(self): """Successful list should return JSON response with series metadata.""" mock_series = [ {"app": "mysql", "namespace": "production"}, {"app": "tidb", "namespace": "production"}, ] with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster") as mock_get_client: mock_client = AsyncMock(spec=LokiClient) mock_client.list_series = AsyncMock(return_value=mock_series) mock_get_client.return_value = (mock_client, "us-east-1") list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=['{app="mysql"}'], cluster_id="cluster-1", env="prod", deploy_type="dedicated", ) assert len(result) == 1 assert result[0]["type"] == "text" response_data = json.loads(result[0]["text"]) assert response_data["status"] == "success" assert "data" in response_data assert response_data["data"] == mock_series @pytest.mark.asyncio async def test_auth_service_error_returns_error(self): """Auth service error should return error message.""" with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster") as mock_get_client: mock_get_client.side_effect = AuthServiceError("Observability URL not configured") list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=['{app="mysql"}'], cluster_id="cluster-1", env="prod", deploy_type="dedicated", ) assert len(result) == 1 assert result[0]["type"] == "text" assert "error" in result[0]["text"].lower() @pytest.mark.asyncio async def test_empty_match_returns_error(self): """Empty match list should return validation error.""" with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster"): list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=[], cluster_id="cluster-1", env="prod", deploy_type="dedicated", ) assert len(result) == 1 assert result[0]["type"] == "text" assert "error" in result[0]["text"].lower() or "at least" in result[0]["text"].lower() @pytest.mark.asyncio async def test_list_with_multiple_match_patterns(self): """List with multiple match patterns should join them with comma.""" mock_series = [{"app": "mysql"}, {"app": "tidb"}] with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster") as mock_get_client: mock_client = AsyncMock(spec=LokiClient) mock_client.list_series = AsyncMock(return_value=mock_series) mock_get_client.return_value = (mock_client, "us-east-1") list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=['{app="mysql"}', '{app="tidb"}'], cluster_id="cluster-1", env="prod", deploy_type="dedicated", ) assert len(result) == 1 assert result[0]["type"] == "text" mock_client.list_series.assert_called_once() call_kwargs = mock_client.list_series.call_args.kwargs assert call_kwargs["match"] == '{app="mysql"},{app="tidb"}' @pytest.mark.asyncio async def test_list_with_optional_parameters(self): """List with optional parameters (start, end) should work.""" mock_series = [{"app": "mysql"}] with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster") as mock_get_client: mock_client = AsyncMock(spec=LokiClient) mock_client.list_series = AsyncMock(return_value=mock_series) mock_get_client.return_value = (mock_client, "us-east-1") list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=['{app="mysql"}'], cluster_id="cluster-1", env="prod", deploy_type="dedicated", start="2024-01-01T00:00:00Z", end="2024-01-01T23:59:59Z", ) assert len(result) == 1 assert result[0]["type"] == "text" mock_client.list_series.assert_called_once() call_kwargs = mock_client.list_series.call_args.kwargs assert call_kwargs["start"] == "2024-01-01T00:00:00Z" assert call_kwargs["end"] == "2024-01-01T23:59:59Z" @pytest.mark.asyncio async def test_timeout_returns_error(self): """Request timeout should return timeout error message.""" with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster") as mock_get_client: mock_client = AsyncMock(spec=LokiClient) mock_client.list_series = AsyncMock( side_effect=LokiTimeoutError( "Series discovery request exceeded 15-second timeout" ) ) mock_get_client.return_value = (mock_client, "us-east-1") list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=['{app="mysql"}'], cluster_id="cluster-1", env="prod", deploy_type="dedicated", ) assert len(result) == 1 assert result[0]["type"] == "text" assert "timeout" in result[0]["text"].lower() @pytest.mark.asyncio async def test_network_error_returns_error(self): """Network error should return error message.""" with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster") as mock_get_client: mock_client = AsyncMock(spec=LokiClient) mock_client.list_series = AsyncMock( side_effect=NetworkError("Loki backend is unreachable") ) mock_get_client.return_value = (mock_client, "us-east-1") list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=['{app="mysql"}'], cluster_id="cluster-1", env="prod", deploy_type="dedicated", ) assert len(result) == 1 assert result[0]["type"] == "text" assert "unreachable" in result[0]["text"].lower() or "network" in result[0]["text"].lower() @pytest.mark.asyncio async def test_validation_error_returns_error(self): """Validation error should return error message.""" with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster") as mock_get_client: mock_get_client.side_effect = ValidationError("Invalid parameters") list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=['{app="mysql"}'], cluster_id="cluster-1", env="prod", deploy_type="dedicated", ) assert len(result) == 1 assert result[0]["type"] == "text" assert "error" in result[0]["text"].lower() @pytest.mark.asyncio async def test_generic_exception_returns_error(self): """Generic exception should return error message.""" with patch( "src.services.auth.get_valid_server_auth", new_callable=AsyncMock, ): with patch("src.services.loki_resolve.get_loki_client_for_cluster") as mock_get_client: mock_get_client.side_effect = Exception("Unexpected error") list_loki_series = _import_list_loki_series_tool() result = await list_loki_series( match=['{app="mysql"}'], cluster_id="cluster-1", env="prod", deploy_type="dedicated", ) assert len(result) == 1 assert result[0]["type"] == "text" assert "error" in result[0]["text"].lower()