"""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