Initial version

This commit is contained in:
Tom Kasper
2026-08-30 20:44:00 +02:00
commit 9aa2da72b5
612 changed files with 64514 additions and 0 deletions
+138
View File
@@ -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