Initial version
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"""elabftw.py — async adapter over vendored elabapi_python (plan §3, §12).
|
||||
|
||||
Error normalization: ApiException status → typed error; network failures →
|
||||
api_error; the API key must never leak into messages. Sync client calls run
|
||||
off the event loop.
|
||||
|
||||
Convention under test: the adapter resolves vendored API classes as module
|
||||
globals of ``mcp_server.elabftw`` and instantiates them with an api_client;
|
||||
tests stub those globals (see tests/fakes.stub_elabapi). Routes use the
|
||||
``"*"`` wildcard method so the suite doesn't pin the generated method names.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from elabapi_python.rest import ApiException
|
||||
|
||||
from fakes import StubElabapi, stub_elabapi
|
||||
from mcp_server.errors import ApiError, AuthError, NotFoundError, PermissionDeniedError
|
||||
from mcp_server.elabftw import ElabftwClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub() -> StubElabapi:
|
||||
return StubElabapi()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(settings, hub, monkeypatch) -> ElabftwClient:
|
||||
stub_elabapi(monkeypatch, hub)
|
||||
return ElabftwClient(settings)
|
||||
|
||||
|
||||
INFO_PAYLOAD = {"elabftw_version": "5.0.0", "api": "2"}
|
||||
EXPERIMENT_PAYLOAD = {"id": 123, "title": "Plasmid prep"}
|
||||
|
||||
|
||||
async def test_get_info_returns_schema(adapter, hub):
|
||||
hub.route("InfoApi", "*", lambda **kw: INFO_PAYLOAD)
|
||||
info = await adapter.get_info()
|
||||
assert info.elabftw_version == "5.0.0"
|
||||
|
||||
|
||||
async def test_get_experiment_maps_payload(adapter, hub):
|
||||
hub.route("ExperimentsApi", "*", lambda **kw: EXPERIMENT_PAYLOAD)
|
||||
experiment = await adapter.get_experiment(123)
|
||||
assert experiment.id == 123
|
||||
assert experiment.title == "Plasmid prep"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected"),
|
||||
[(401, AuthError), (403, PermissionDeniedError), (404, NotFoundError), (500, ApiError)],
|
||||
)
|
||||
async def test_http_status_normalization(adapter, hub, status, expected):
|
||||
def boom(**kw):
|
||||
raise ApiException(status=status, reason="boom")
|
||||
|
||||
hub.route("ExperimentsApi", "*", boom)
|
||||
with pytest.raises(expected):
|
||||
await adapter.get_experiment(123)
|
||||
|
||||
|
||||
async def test_network_failure_becomes_api_error(adapter, hub):
|
||||
def boom(**kw):
|
||||
raise OSError("connection refused")
|
||||
|
||||
hub.route("ExperimentsApi", "*", boom)
|
||||
with pytest.raises(ApiError):
|
||||
await adapter.get_experiment(123)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403, 404, 500])
|
||||
async def test_api_key_never_leaks_into_error_messages(settings, hub, monkeypatch, status):
|
||||
stub_elabapi(monkeypatch, hub)
|
||||
|
||||
def boom(**kw):
|
||||
raise ApiException(
|
||||
status=status, reason="boom", body="key=labvoice-secret-key-do-not-leak"
|
||||
)
|
||||
|
||||
hub.route("ExperimentsApi", "*", boom)
|
||||
client = ElabftwClient(settings)
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await client.get_experiment(123)
|
||||
assert "labvoice-secret-key-do-not-leak" not in str(excinfo.value)
|
||||
assert "labvoice-secret-key-do-not-leak" not in repr(excinfo.value)
|
||||
|
||||
|
||||
async def test_sync_calls_run_off_the_event_loop(adapter, hub):
|
||||
main_thread = threading.get_ident()
|
||||
|
||||
def whoami(**kw):
|
||||
assert threading.get_ident() != main_thread
|
||||
return EXPERIMENT_PAYLOAD
|
||||
|
||||
hub.route("ExperimentsApi", "*", whoami)
|
||||
await adapter.get_experiment(123)
|
||||
|
||||
|
||||
async def test_steps_endpoint_returns_steps_schema(adapter, hub):
|
||||
hub.route(
|
||||
"StepsApi",
|
||||
"*",
|
||||
lambda **kw: [{"id": 9, "body": "Add ethanol", "finished": False, "ordering": 1}],
|
||||
)
|
||||
steps = await adapter.get_steps("experiments", 123)
|
||||
assert steps[0].id == 9
|
||||
|
||||
|
||||
async def test_containers_endpoint_returns_container_links(adapter, hub):
|
||||
hub.route(
|
||||
"ContainersApi",
|
||||
"*",
|
||||
lambda **kw: [{"id": 31, "qty_stored": 50.0, "unit": "mL"}],
|
||||
)
|
||||
containers = await adapter.get_containers("items", 12)
|
||||
assert containers[0].id == 31
|
||||
assert containers[0].qty_stored == 50.0
|
||||
|
||||
|
||||
async def test_search_normalizes_pagination(adapter, hub):
|
||||
seen = {}
|
||||
|
||||
def capture(**kwargs):
|
||||
seen.update(kwargs)
|
||||
return [EXPERIMENT_PAYLOAD]
|
||||
|
||||
hub.route("ExperimentsApi", "*", capture)
|
||||
results = await adapter.search_experiments(q="plasmid", limit=5, offset=10)
|
||||
assert results[0].id == 123
|
||||
assert seen, "search must reach the client"
|
||||
assert "q" in str(seen) or "query" in str(seen)
|
||||
|
||||
|
||||
async def test_client_keeps_settings(settings):
|
||||
client = ElabftwClient(settings)
|
||||
assert client.settings is settings
|
||||
Reference in New Issue
Block a user