Skip to content

Commit 4573f6e

Browse files
authored
Merge pull request #193 from IBM/test-coverage
Test coverage
2 parents fe7d88f + 3661a5a commit 4573f6e

File tree

4 files changed

+1307
-140
lines changed

4 files changed

+1307
-140
lines changed

alembic/versions/e4fc04d1a442_add_annotations_to_tables.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# -*- coding: utf-8 -*-
12
"""Add annotations to tables
23
34
Revision ID: e4fc04d1a442
Lines changed: 214 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,276 @@
11
# -*- coding: utf-8 -*-
2-
"""Memory-backend unit tests for `session_registry.py`.
2+
"""Memory-backend unit tests for ``session_registry.py``.
33
44
Copyright 2025
55
SPDX-License-Identifier: Apache-2.0
6-
Author: Mihai Criveti
7-
8-
These tests cover the essential public behaviours of SessionRegistry when
9-
configured with backend="memory":
10-
11-
* add_session / get_session / get_session_sync
12-
* remove_session (disconnects transport & clears cache)
13-
* broadcast + respond (with generate_response monkey-patched)
14-
15-
No Redis or SQLAlchemy fixtures are required, making the suite fast and
16-
portable.
6+
Authors: Mihai Criveti
7+
8+
This suite exercises the in-memory implementation of
9+
:pyfile:`mcpgateway/cache/session_registry.py`.
10+
11+
Covered behaviours
12+
------------------
13+
* add_session / get_session / get_session_sync / remove_session
14+
* broadcast -> respond for **dict**, **list**, **str** payloads
15+
* generate_response branches:
16+
• initialize (result + notifications)
17+
• ping
18+
• tools/list (with stubbed service + DB)
19+
* handle_initialize_logic success, and both error branches
1720
"""
1821

22+
from __future__ import annotations
23+
1924
import asyncio
25+
import json
26+
import re
27+
from typing import Any, Dict, List
2028

2129
import pytest
30+
from fastapi import HTTPException
2231

23-
# Import SessionRegistry – works whether the file lives inside the package or beside it
32+
# Prefer installed package; otherwise fall back to local file when running
33+
# outside the project tree (keeps tests developer-friendly).
2434
try:
2535
from mcpgateway.cache.session_registry import SessionRegistry
36+
from mcpgateway.config import settings
2637
except (ModuleNotFoundError, ImportError): # pragma: no cover
2738
from session_registry import SessionRegistry # type: ignore
2839

40+
settings = type(
41+
"S",
42+
(),
43+
{
44+
"protocol_version": "1.0",
45+
"federation_timeout": 1,
46+
"skip_ssl_verify": True,
47+
},
48+
)
49+
2950

51+
# --------------------------------------------------------------------------- #
52+
# Minimal SSE transport stub #
53+
# --------------------------------------------------------------------------- #
3054
class FakeSSETransport:
31-
"""Minimal stub implementing only the methods SessionRegistry uses."""
55+
"""Stub implementing just the subset of the API used by SessionRegistry."""
3256

3357
def __init__(self, session_id: str):
3458
self.session_id = session_id
3559
self._connected = True
36-
self.sent_messages = []
60+
self.sent: List[Any] = []
3761

38-
async def disconnect(self):
62+
async def disconnect(self) -> None: # noqa: D401
3963
self._connected = False
4064

41-
async def is_connected(self):
65+
async def is_connected(self) -> bool: # noqa: D401
4266
return self._connected
4367

44-
async def send_message(self, msg):
45-
self.sent_messages.append(msg)
46-
47-
48-
# ---------------------------------------------------------------------------
49-
# Pytest fixtures
50-
# ---------------------------------------------------------------------------
68+
async def send_message(self, msg) -> None: # noqa: D401
69+
# Deep-copy through JSON round-trip for realism
70+
self.sent.append(json.loads(json.dumps(msg)))
5171

5272

73+
# --------------------------------------------------------------------------- #
74+
# Event-loop fixture (pytest default loop is function-scoped) #
75+
# --------------------------------------------------------------------------- #
5376
@pytest.fixture(name="event_loop")
5477
def _event_loop_fixture():
78+
"""Provide a fresh asyncio loop for these async tests."""
5579
loop = asyncio.new_event_loop()
5680
yield loop
5781
loop.close()
5882

5983

84+
# --------------------------------------------------------------------------- #
85+
# SessionRegistry fixture (memory backend) #
86+
# --------------------------------------------------------------------------- #
6087
@pytest.fixture()
61-
async def registry():
88+
async def registry() -> SessionRegistry:
89+
"""Initialise an in-memory SessionRegistry and tear it down after tests."""
6290
reg = SessionRegistry(backend="memory")
6391
await reg.initialize()
6492
yield reg
6593
await reg.shutdown()
6694

6795

68-
# ---------------------------------------------------------------------------
69-
# Tests
70-
# ---------------------------------------------------------------------------
96+
# --------------------------------------------------------------------------- #
97+
# Core CRUD behaviour #
98+
# --------------------------------------------------------------------------- #
99+
@pytest.mark.asyncio
100+
async def test_add_get_remove(registry: SessionRegistry):
101+
"""Add ➜ get (async & sync) ➜ remove and verify cache/state."""
102+
tr = FakeSSETransport("A")
103+
await registry.add_session("A", tr)
71104

105+
assert await registry.get_session("A") is tr
106+
assert registry.get_session_sync("A") is tr
107+
assert await registry.get_session("missing") is None
72108

73-
@pytest.mark.asyncio
74-
async def test_add_and_get_session(registry):
75-
tr = FakeSSETransport("abc")
76-
await registry.add_session("abc", tr)
109+
# Remove twice – second call must be harmless
110+
await registry.remove_session("A")
111+
await registry.remove_session("A")
77112

78-
assert await registry.get_session("abc") is tr
79-
assert registry.get_session_sync("abc") is tr
113+
assert not await tr.is_connected()
114+
assert registry.get_session_sync("A") is None
80115

81116

117+
# --------------------------------------------------------------------------- #
118+
# broadcast ➜ respond with different payload types #
119+
# --------------------------------------------------------------------------- #
82120
@pytest.mark.asyncio
83-
async def test_remove_session(registry):
84-
tr = FakeSSETransport("dead")
85-
await registry.add_session("dead", tr)
121+
@pytest.mark.parametrize(
122+
"payload",
123+
[
124+
{"method": "ping", "id": 1, "params": {}}, # dict
125+
["x", "y", 42], # list
126+
"plain-string", # str
127+
],
128+
)
129+
async def test_broadcast_and_respond(payload, monkeypatch, registry: SessionRegistry):
130+
"""broadcast stores the payload; respond routes it to generate_response."""
131+
tr = FakeSSETransport("B")
132+
await registry.add_session("B", tr)
133+
134+
captured: Dict[str, Any] = {}
135+
136+
# Patch generate_response so we can verify it's called with our payload:
137+
async def fake_generate_response(*, message, transport, **kwargs): # noqa: D401
138+
captured["message"] = message
139+
captured["transport"] = transport
140+
captured["kwargs"] = kwargs
86141

87-
await registry.remove_session("dead")
142+
monkeypatch.setattr(registry, "generate_response", fake_generate_response)
88143

89-
assert not await tr.is_connected()
90-
assert registry.get_session_sync("dead") is None
144+
await registry.broadcast("B", payload)
145+
await registry.respond(server_id=None, user={}, session_id="B", base_url="http://localhost")
146+
147+
assert captured["transport"] is tr
148+
assert captured["message"] == payload
149+
150+
151+
# --------------------------------------------------------------------------- #
152+
# Fixtures to stub get_db and the three *Service objects #
153+
# --------------------------------------------------------------------------- #
154+
@pytest.fixture()
155+
def stub_db(monkeypatch):
156+
"""Patch ``get_db`` to return a synchronous dummy iterator."""
157+
158+
def _dummy_iter():
159+
yield None
160+
161+
monkeypatch.setattr(
162+
"mcpgateway.cache.session_registry.get_db",
163+
lambda: _dummy_iter(),
164+
raising=False,
165+
)
166+
167+
168+
@pytest.fixture()
169+
def stub_services(monkeypatch):
170+
"""Replace list_* service methods so they return predictable data."""
91171

172+
class _Item:
173+
def model_dump(self, *_, **__) -> Dict[str, str]: # noqa: D401
174+
return {"name": "demo"}
92175

176+
async def _return_items(*args, **kwargs): # noqa: D401
177+
return [_Item()]
178+
179+
mod = "mcpgateway.cache.session_registry"
180+
monkeypatch.setattr(f"{mod}.tool_service.list_tools", _return_items, raising=False)
181+
monkeypatch.setattr(f"{mod}.prompt_service.list_prompts", _return_items, raising=False)
182+
monkeypatch.setattr(f"{mod}.resource_service.list_resources", _return_items, raising=False)
183+
184+
185+
# --------------------------------------------------------------------------- #
186+
# generate_response branches #
187+
# --------------------------------------------------------------------------- #
93188
@pytest.mark.asyncio
94-
async def test_broadcast_and_respond(monkeypatch, registry):
95-
"""Ensure broadcast stores the message and respond delivers it via generate_response."""
96-
tr = FakeSSETransport("xyz")
97-
await registry.add_session("xyz", tr)
189+
async def test_generate_response_initialize(registry: SessionRegistry):
190+
"""The *initialize* branch sends result + notifications (>= 5 messages)."""
191+
tr = FakeSSETransport("init")
192+
await registry.add_session("init", tr)
193+
194+
msg = {
195+
"method": "initialize",
196+
"id": 101,
197+
"params": {"protocol_version": settings.protocol_version},
198+
}
199+
await registry.generate_response(
200+
message=msg,
201+
transport=tr,
202+
server_id=None,
203+
user={},
204+
base_url="http://host",
205+
)
98206

99-
captured = {}
207+
# Implementation may emit 5 or 6 messages (roots/list_changed optional)
208+
assert len(tr.sent) >= 5
100209

101-
async def fake_generate_response(*, message, transport, **_):
102-
captured["transport"] = transport
103-
captured["message"] = message
210+
first = tr.sent[0]
211+
assert first["id"] == 101
212+
assert first["result"]["protocolVersion"] == settings.protocol_version
213+
assert re.match(r"notifications/initialized$", tr.sent[1]["method"])
104214

105-
monkeypatch.setattr(registry, "generate_response", fake_generate_response)
106215

107-
ping_msg = {"method": "ping", "id": 1, "params": {}}
108-
await registry.broadcast("xyz", ping_msg)
216+
@pytest.mark.asyncio
217+
async def test_generate_response_ping(registry: SessionRegistry):
218+
"""The *ping* branch should echo an empty result."""
219+
tr = FakeSSETransport("ping")
220+
await registry.add_session("ping", tr)
221+
222+
msg = {"method": "ping", "id": 77, "params": {}}
223+
await registry.generate_response(
224+
message=msg,
225+
transport=tr,
226+
server_id=None,
227+
user={},
228+
base_url="http://host",
229+
)
230+
231+
assert tr.sent[-1] == {"jsonrpc": "2.0", "result": {}, "id": 77}
232+
109233

110-
# respond should call our fake_generate_response exactly once
111-
await registry.respond(
234+
@pytest.mark.asyncio
235+
async def test_generate_response_tools_list(registry: SessionRegistry, stub_db, stub_services):
236+
"""*tools/list* responds with the stubbed ToolService payload."""
237+
tr = FakeSSETransport("tools")
238+
await registry.add_session("tools", tr)
239+
240+
msg = {"method": "tools/list", "id": 42, "params": {}}
241+
await registry.generate_response(
242+
message=msg,
243+
transport=tr,
112244
server_id=None,
113245
user={},
114-
session_id="xyz",
115-
base_url="http://localhost",
246+
base_url="http://host",
116247
)
117248

118-
assert captured["transport"] is tr
119-
assert captured["message"] == ping_msg
249+
reply = tr.sent[-1]
250+
assert reply["id"] == 42
251+
assert reply["result"]["tools"] == [{"name": "demo"}]
252+
253+
254+
# --------------------------------------------------------------------------- #
255+
# handle_initialize_logic success & errors #
256+
# --------------------------------------------------------------------------- #
257+
@pytest.mark.asyncio
258+
async def test_handle_initialize_success(registry: SessionRegistry):
259+
body = {"protocol_version": settings.protocol_version}
260+
res = await registry.handle_initialize_logic(body)
261+
assert res.protocol_version == settings.protocol_version
262+
263+
264+
@pytest.mark.asyncio
265+
async def test_handle_initialize_missing_version_error(registry: SessionRegistry):
266+
with pytest.raises(HTTPException) as exc:
267+
await registry.handle_initialize_logic({})
268+
assert exc.value.headers["MCP-Error-Code"] == "-32002"
269+
270+
271+
@pytest.mark.asyncio
272+
async def test_handle_initialize_unsupported_version_error(registry: SessionRegistry):
273+
body = {"protocol_version": "999"}
274+
with pytest.raises(HTTPException) as exc:
275+
await registry.handle_initialize_logic(body)
276+
assert exc.value.headers["MCP-Error-Code"] == "-32003"

0 commit comments

Comments
 (0)