Skip to main content
Glama
test_lookups.py11.6 kB
"""Unit tests for lookups collection helpers. Covers success paths, 404 handling per-group, error propagation, and HTTP client errors. """ # pyright: reportPrivateUsage=false from unittest.mock import AsyncMock, MagicMock import httpx import pytest from cribl_control_plane.errors import CriblControlPlaneError from cribl_control_plane.models.productscore import ProductsCore from cribl_control_plane.models.security import Security from fastmcp import Context from snc_cribl_mcp.operations.common import serialize_model from snc_cribl_mcp.operations.lookups import collect_product_lookups @pytest.fixture def mock_ctx() -> Context: """Provide a Context-like AsyncMock for tool logging.""" ctx = MagicMock(spec=Context) ctx.info = AsyncMock() ctx.warning = AsyncMock() return ctx @pytest.fixture def security() -> Security: """Provide a mock Security object with bearer auth.""" return Security(bearer_auth="test-token") class TestSerializeModel: """Tests for the serialize_model helper function.""" def test_serialize_model_with_pydantic_model(self) -> None: """Objects with model_dump should be serialized correctly.""" mock_obj = MagicMock() mock_obj.model_dump.return_value = {"id": "test", "name": "lookup1"} result = serialize_model(mock_obj) assert result == {"id": "test", "name": "lookup1"} mock_obj.model_dump.assert_called_once_with(mode="json", exclude_none=True) def test_serialize_model_without_model_dump(self) -> None: """Objects without model_dump should return empty dict.""" obj = object() result = serialize_model(obj) assert result == {} def test_serialize_model_raises_type_error(self) -> None: """Objects where model_dump raises TypeError should return empty dict.""" mock_obj = MagicMock() mock_obj.model_dump.side_effect = TypeError("Invalid type") result = serialize_model(mock_obj) assert result == {} def test_serialize_model_raises_value_error(self) -> None: """Objects where model_dump raises ValueError should return empty dict.""" mock_obj = MagicMock() mock_obj.model_dump.side_effect = ValueError("Invalid value") result = serialize_model(mock_obj) assert result == {} @pytest.mark.asyncio async def test_collect_product_lookups_success(mock_ctx: Context, security: Security) -> None: """It should list lookups for each group via HTTP requests and aggregate results.""" mock_client = MagicMock() groups_response = MagicMock(items=[MagicMock(), MagicMock()]) groups_response.items[0].model_dump.return_value = {"id": "g1"} groups_response.items[1].model_dump.return_value = {"id": "g2"} mock_client.groups.list_async = AsyncMock(return_value=groups_response) mock_http_client = AsyncMock() mock_client.sdk_configuration = MagicMock( server_url="https://example/api/v1", async_client=mock_http_client, ) # First group returns 2 items, second returns 1 resp_g1 = MagicMock() resp_g1.status_code = 200 resp_g1.json.return_value = {"items": [{"id": "l1"}, {"id": "l2"}], "count": 2} resp_g2 = MagicMock() resp_g2.status_code = 200 resp_g2.json.return_value = {"items": [{"id": "l3"}], "count": 1} async def http_get(url: str, **_kwargs: object) -> MagicMock: if "/m/g1/system/lookups" in url: return resp_g1 if "/m/g2/system/lookups" in url: return resp_g2 return MagicMock(status_code=404) mock_http_client.get = AsyncMock(side_effect=http_get) result = await collect_product_lookups( mock_client, security, product=ProductsCore.STREAM, timeout_ms=10000, ctx=mock_ctx, ) assert result["status"] == "ok" assert result["total_count"] == 3 assert len(result["groups"]) == 2 assert result["groups"][0]["group_id"] == "g1" assert result["groups"][0]["count"] == 2 assert result["groups"][0]["reported_count"] == 2 @pytest.mark.asyncio async def test_collect_product_lookups_404_per_group(mock_ctx: Context, security: Security) -> None: """404 on a group's lookups should be treated as empty for that group.""" mock_client = MagicMock() groups_response = MagicMock(items=[MagicMock()]) groups_response.items[0].model_dump.return_value = {"id": "g404"} mock_client.groups.list_async = AsyncMock(return_value=groups_response) mock_http_client = AsyncMock() mock_client.sdk_configuration = MagicMock( server_url="https://example/api/v1", async_client=mock_http_client, ) resp_404 = MagicMock() resp_404.status_code = 404 mock_http_client.get.return_value = resp_404 result = await collect_product_lookups( mock_client, security, product=ProductsCore.STREAM, timeout_ms=10000, ctx=mock_ctx, ) assert result["status"] == "ok" assert result["total_count"] == 0 assert result["groups"][0]["group_id"] == "g404" assert result["groups"][0]["count"] == 0 # warning should have been awaited at least once assert getattr(mock_ctx.warning, "await_count", 0) >= 1 @pytest.mark.asyncio async def test_collect_product_lookups_http_error_per_group(mock_ctx: Context, security: Security) -> None: """HTTP errors on a group's lookups should raise RuntimeError.""" mock_client = MagicMock() groups_response = MagicMock(items=[MagicMock()]) groups_response.items[0].model_dump.return_value = {"id": "g1"} mock_client.groups.list_async = AsyncMock(return_value=groups_response) mock_http_client = AsyncMock() mock_client.sdk_configuration = MagicMock( server_url="https://example/api/v1", async_client=mock_http_client, ) mock_http_client.get.side_effect = httpx.ConnectError("Connection failed") with pytest.raises(RuntimeError, match="Network error while listing lookups"): await collect_product_lookups( mock_client, security, product=ProductsCore.STREAM, timeout_ms=10000, ctx=mock_ctx, ) @pytest.mark.asyncio async def test_collect_product_lookups_unavailable_product(mock_ctx: Context, security: Security) -> None: """If listing groups returns 404, the function should return an 'unavailable' status.""" mock_client = MagicMock() api_error_404 = CriblControlPlaneError( message="Not found", body=None, raw_response=MagicMock(status_code=404), ) mock_client.groups.list_async = AsyncMock(side_effect=api_error_404) result = await collect_product_lookups( mock_client, security, product=ProductsCore.EDGE, timeout_ms=10000, ctx=mock_ctx, ) assert result["status"] == "unavailable" assert result["total_count"] == 0 assert result["groups"] == [] @pytest.mark.asyncio async def test_collect_product_lookups_api_error_non_404(mock_ctx: Context, security: Security) -> None: """Non-404 API errors on groups listing should raise RuntimeError.""" mock_client = MagicMock() api_error_500 = CriblControlPlaneError( message="Server error", body=None, raw_response=MagicMock(status_code=500), ) mock_client.groups.list_async = AsyncMock(side_effect=api_error_500) with pytest.raises(RuntimeError, match="Cribl API error while listing"): await collect_product_lookups( mock_client, security, product=ProductsCore.STREAM, timeout_ms=10000, ctx=mock_ctx, ) @pytest.mark.asyncio async def test_collect_product_lookups_network_error_on_groups(mock_ctx: Context, security: Security) -> None: """Network errors while listing groups should raise RuntimeError.""" mock_client = MagicMock() mock_client.groups.list_async = AsyncMock(side_effect=httpx.ConnectError("fail")) with pytest.raises(RuntimeError, match="Network error while listing"): await collect_product_lookups( mock_client, security, product=ProductsCore.STREAM, timeout_ms=10000, ctx=mock_ctx, ) @pytest.mark.asyncio async def test_collect_product_lookups_skips_groups_without_id(mock_ctx: Context, security: Security) -> None: """Groups without an id or groupId should be skipped.""" mock_client = MagicMock() groups_response = MagicMock(items=[MagicMock(), MagicMock()]) # First group has no id, second has id groups_response.items[0].model_dump.return_value = {"name": "no_id_group"} groups_response.items[1].model_dump.return_value = {"id": "g1"} mock_client.groups.list_async = AsyncMock(return_value=groups_response) mock_http_client = AsyncMock() mock_client.sdk_configuration = MagicMock( server_url="https://example/api/v1", async_client=mock_http_client, ) resp_g1 = MagicMock() resp_g1.status_code = 200 resp_g1.json.return_value = {"items": [{"id": "l1"}], "count": 1} mock_http_client.get.return_value = resp_g1 result = await collect_product_lookups( mock_client, security, product=ProductsCore.STREAM, timeout_ms=10000, ctx=mock_ctx, ) # Only one group should be processed assert len(result["groups"]) == 1 assert result["groups"][0]["group_id"] == "g1" @pytest.mark.asyncio async def test_collect_product_lookups_with_groupid_field(mock_ctx: Context, security: Security) -> None: """Groups with groupId field instead of id should work.""" mock_client = MagicMock() groups_response = MagicMock(items=[MagicMock()]) groups_response.items[0].model_dump.return_value = {"groupId": "fleet1"} mock_client.groups.list_async = AsyncMock(return_value=groups_response) mock_http_client = AsyncMock() mock_client.sdk_configuration = MagicMock( server_url="https://example/api/v1", async_client=mock_http_client, ) resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"items": [{"id": "l1"}]} mock_http_client.get.return_value = resp result = await collect_product_lookups( mock_client, security, product=ProductsCore.EDGE, timeout_ms=10000, ctx=mock_ctx, ) assert result["groups"][0]["group_id"] == "fleet1" @pytest.mark.asyncio async def test_collect_product_lookups_without_bearer_auth(mock_ctx: Context) -> None: """Test that lookups can be collected even without bearer auth in Security.""" security_no_auth = Security() mock_client = MagicMock() groups_response = MagicMock(items=[MagicMock()]) groups_response.items[0].model_dump.return_value = {"id": "g1"} mock_client.groups.list_async = AsyncMock(return_value=groups_response) mock_http_client = AsyncMock() mock_client.sdk_configuration = MagicMock( server_url="https://example/api/v1", async_client=mock_http_client, ) resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"items": []} mock_http_client.get.return_value = resp result = await collect_product_lookups( mock_client, security_no_auth, product=ProductsCore.STREAM, timeout_ms=10000, ctx=mock_ctx, ) assert result["status"] == "ok" # Verify no Authorization header was passed call_kwargs = mock_http_client.get.call_args.kwargs assert "Authorization" not in call_kwargs.get("headers", {})

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/atree1023/snc-cribl-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server