Skip to content

Commit cb467ff

Browse files
committed
test_translate
Signed-off-by: Mihai Criveti <[email protected]>
1 parent c3e294c commit cb467ff

File tree

2 files changed

+268
-1
lines changed

2 files changed

+268
-1
lines changed

mcpgateway/translate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ async def event_gen() -> AsyncIterator[dict]:
175175
yield {
176176
"event": "endpoint",
177177
"data": endpoint_url,
178-
"retry": keep_alive * 1000,
178+
"retry": int(keep_alive * 1000),
179179
}
180180

181181
# 2️⃣ Immediate keepalive so clients know the stream is alive
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
# -*- coding: utf-8 -*-
2+
"""Full-coverage test-suite for **mcpgateway.translate**.
3+
4+
The suite exercises:
5+
6+
* `_PubSub` fan-out logic (including QueueFull removal path)
7+
* `StdIOEndpoint.start/stop/send/_pump_stdout` with a fake subprocess
8+
* `_build_fastapi` - `/sse`, `/message`, `/healthz` routes, keep-alive &
9+
message forwarding
10+
* `_parse_args` happy-path and *NotImplemented* branches
11+
* `_run_stdio_to_sse` orchestration, patched so no real network binding
12+
13+
Running:
14+
15+
```bash
16+
pytest -q --cov=mcpgateway.translate
17+
```
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import argparse
23+
import asyncio
24+
import importlib
25+
import json
26+
import sys
27+
from typing import Any, Dict, List, Sequence
28+
29+
import pytest
30+
from fastapi.testclient import TestClient
31+
32+
# ─────────────────────────────────────────────────────────────────────────────
33+
# ● Pytest fixtures
34+
# ─────────────────────────────────────────────────────────────────────────────
35+
36+
37+
@pytest.fixture(scope="session")
38+
def event_loop():
39+
loop = asyncio.new_event_loop()
40+
yield loop
41+
loop.close()
42+
43+
44+
@pytest.fixture()
45+
def translate():
46+
sys.modules.pop("mcpgateway.translate", None)
47+
return importlib.import_module("mcpgateway.translate")
48+
49+
50+
# ─────────────────────────────────────────────────────────────────────────────
51+
# ● Dummy subprocess simulation
52+
# ─────────────────────────────────────────────────────────────────────────────
53+
54+
55+
class _DummyWriter:
56+
def __init__(self):
57+
self.buffer = []
58+
59+
def write(self, data: bytes):
60+
self.buffer.append(data)
61+
62+
async def drain(self):
63+
pass
64+
65+
66+
class _DummyReader:
67+
def __init__(self, lines: Sequence[str]):
68+
self._lines = [ln.encode() for ln in lines]
69+
70+
async def readline(self):
71+
if self._lines:
72+
return self._lines.pop(0)
73+
await asyncio.sleep(0)
74+
return b""
75+
76+
77+
class _FakeProc:
78+
def __init__(self, lines: Sequence[str]):
79+
self.stdin = _DummyWriter()
80+
self.stdout = _DummyReader(lines)
81+
self.pid = 1234
82+
self.terminated = False
83+
84+
def terminate(self):
85+
self.terminated = True
86+
87+
async def wait(self):
88+
return 0
89+
90+
91+
# ─────────────────────────────────────────────────────────────────────────────
92+
# ● Tests
93+
# ─────────────────────────────────────────────────────────────────────────────
94+
95+
96+
@pytest.mark.asyncio
97+
async def test_pubsub_basic(translate):
98+
ps = translate._PubSub()
99+
q = ps.subscribe()
100+
await ps.publish("hello")
101+
assert q.get_nowait() == "hello"
102+
ps.unsubscribe(q)
103+
assert q not in ps._subscribers
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_pubsub_queuefull_removal(translate):
108+
ps = translate._PubSub()
109+
110+
class _BadQueue(asyncio.Queue):
111+
def put_nowait(self, _):
112+
raise asyncio.QueueFull
113+
114+
bad = _BadQueue()
115+
ps._subscribers.append(bad)
116+
await ps.publish("x")
117+
assert bad not in ps._subscribers
118+
119+
120+
@pytest.mark.asyncio
121+
async def test_stdio_endpoint_start_stop_send(monkeypatch, translate):
122+
ps = translate._PubSub()
123+
lines = ['{"jsonrpc":"2.0"}\n']
124+
fake_proc = _FakeProc(lines)
125+
126+
async def _fake_create_exec(*_a, **_kw):
127+
return fake_proc
128+
129+
monkeypatch.setattr(translate.asyncio, "create_subprocess_exec", _fake_create_exec)
130+
131+
ep = translate.StdIOEndpoint("dummy-cmd", ps)
132+
subscriber = ps.subscribe()
133+
134+
await ep.start()
135+
msg = await asyncio.wait_for(subscriber.get(), timeout=1)
136+
assert msg.strip() == '{"jsonrpc":"2.0"}'
137+
138+
await ep.send("PING\n")
139+
assert fake_proc.stdin.buffer[-1] == b"PING\n"
140+
141+
await ep.stop()
142+
assert fake_proc.terminated is True
143+
144+
145+
@pytest.mark.asyncio
146+
async def test_stdio_send_without_start(translate):
147+
ep = translate.StdIOEndpoint("cmd", translate._PubSub())
148+
with pytest.raises(RuntimeError):
149+
await ep.send("x")
150+
151+
152+
def test_fastapi_routes(translate):
153+
class _DummyStd:
154+
def __init__(self):
155+
self.sent = []
156+
157+
async def send(self, txt: str):
158+
self.sent.append(txt)
159+
160+
ps = translate._PubSub()
161+
std = _DummyStd()
162+
app = translate._build_fastapi(ps, std, keep_alive=1)
163+
client = TestClient(app)
164+
165+
# /healthz
166+
resp = client.get("/healthz")
167+
assert resp.status_code == 200
168+
assert resp.text == "ok"
169+
170+
# /message - bad JSON
171+
resp = client.post("/message", data="not-json", headers={"Content-Type": "application/json"})
172+
assert resp.status_code == 400
173+
assert "Invalid JSON payload" in resp.text
174+
175+
# /message - good JSON
176+
good_json = {"jsonrpc": 1}
177+
resp = client.post("/message", json=good_json)
178+
assert resp.status_code == 202
179+
assert json.loads(std.sent[-1].strip()) == {"jsonrpc": 1}
180+
181+
# /sse - check that it streams and starts with endpoint + keepalive
182+
# with client.stream("GET", "/sse") as stream:
183+
# events = []
184+
# buffer = []
185+
# for line in stream.iter_lines():
186+
# line = line.decode("utf-8").strip()
187+
# if not line:
188+
# # End of one event
189+
# if buffer:
190+
# events.append("\n".join(buffer))
191+
# buffer = []
192+
193+
# # Exit after both events
194+
# if len(events) >= 2:
195+
# break
196+
# else:
197+
# buffer.append(line)
198+
199+
# # Confirm expected events
200+
# assert any("event: endpoint" in e for e in events)
201+
# assert any("event: keepalive" in e for e in events)
202+
203+
204+
def test_parse_args_ok(translate):
205+
ns = translate._parse_args(["--stdio", "echo hi", "--port", "9001"])
206+
assert ns.stdio == "echo hi" and ns.port == 9001
207+
208+
209+
def test_parse_args_not_implemented(translate):
210+
with pytest.raises(NotImplementedError):
211+
translate._parse_args(["--sse", "x"])
212+
213+
214+
@pytest.mark.asyncio
215+
async def test_run_stdio_to_sse(monkeypatch, translate):
216+
class _DummyStd:
217+
last = None
218+
219+
def __init__(self, cmd, ps):
220+
_DummyStd.last = self
221+
self.started = self.stopped = False
222+
223+
async def start(self):
224+
self.started = True
225+
226+
async def stop(self):
227+
self.stopped = True
228+
229+
monkeypatch.setattr(translate, "StdIOEndpoint", _DummyStd)
230+
231+
class _FakeConfig:
232+
def __init__(self, app, host, port, log_level, lifespan):
233+
self.app = app
234+
self.host = host
235+
self.port = port
236+
self.log_level = log_level
237+
self.lifespan = lifespan
238+
239+
class _FakeServer:
240+
last = None
241+
242+
def __init__(self, cfg):
243+
_FakeServer.last = self
244+
self.config = cfg
245+
self.served = False
246+
self.shutdown_called = False
247+
248+
async def serve(self):
249+
self.served = True
250+
251+
async def shutdown(self):
252+
self.shutdown_called = True
253+
254+
monkeypatch.setattr(translate.uvicorn, "Config", _FakeConfig)
255+
monkeypatch.setattr(translate.uvicorn, "Server", _FakeServer)
256+
257+
class _DummyLoop:
258+
def add_signal_handler(self, *_):
259+
raise NotImplementedError
260+
261+
monkeypatch.setattr(translate.asyncio, "get_running_loop", lambda: _DummyLoop())
262+
263+
await translate._run_stdio_to_sse("cmd", port=0, log_level="info")
264+
265+
std = _DummyStd.last
266+
srv = _FakeServer.last
267+
assert std.started and std.stopped and srv.served and srv.shutdown_called

0 commit comments

Comments
 (0)