139 lines
4.3 KiB
Python
139 lines
4.3 KiB
Python
"""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
|