"""Test doubles shared across the suite. Three fakes live here, each at a different seam: * :class:`FakeElabftwClient` duck-types the ``ElabftwClient`` adapter interface. It is a stateful in-memory eLabFTW instance used by workflow/service tests, so saga behaviour can be asserted against real state changes. * :class:`StubResolver` duck-types ``ResourceResolver`` with a plain dict of mappings, keeping workflow tests independent of the SQLite mapping store. * :func:`stub_elabapi` installs fake ``elabapi_python`` API classes into the ``mcp_server.elabftw`` module namespace. Convention under test: the adapter resolves vendored API classes as module globals of ``mcp_server.elabftw`` (``from elabapi_python import ExperimentsApi`` ...) and instantiates them with an api_client. If the implementation wires this differently, only the injection point in these stubs needs to move. Scenario builders (``make_experiment``, ``make_item`` ...) produce the standard fixtures referenced throughout the tests. """ from __future__ import annotations from dataclasses import dataclass, field from mcp_server.errors import ClarificationNeededError, NotFoundError, PermissionDeniedError from mcp_server.models import ( AllocationPolicy, MappingEntry, MappingProposal, ) from mcp_server.schemas import ( ContainerLink, ElabftwComment, ElabftwExperiment, ElabftwInfo, ElabftwItem, ElabftwStep, ElabftwTemplate, StorageUnit, ) # --------------------------------------------------------------------------- # Annotation snippets (the labvoice:v1 format from planning ยง4) # --------------------------------------------------------------------------- ANNOTATION_ETHANOL_FIFO = ( "" ) # --------------------------------------------------------------------------- # Fake eLabFTW state (adapter-level) # --------------------------------------------------------------------------- @dataclass class FakeStep: id: int body: str finished: bool = False ordering: int | None = None @dataclass class FakeComment: id: int body: str created_at: str | None = None @dataclass class FakeContainer: sub_id: int qty_stored: float unit: str @dataclass class FakeItem: id: int title: str body: str = "" extra_fields: dict = field(default_factory=dict) containers: list[FakeContainer] = field(default_factory=list) @dataclass class FakeExperiment: id: int title: str status: str = "running" steps: list[FakeStep] = field(default_factory=list) comments: list[FakeComment] = field(default_factory=list) linked_items: list[int] = field(default_factory=list) @dataclass class FakeTemplate: id: int title: str description: str = "" tags: list[str] = field(default_factory=list) steps: list[FakeStep] = field(default_factory=list) def make_template(**overrides) -> FakeTemplate: """Standard template: two plain steps, description with two paragraphs.""" tpl = FakeTemplate( id=7, title="PCR cleanup", description="Purify an amplicon after PCR.\n\nLonger protocol notes follow here.", tags=["dna", "cleanup"], steps=[ FakeStep(id=41, body="Add binding buffer", ordering=1), FakeStep(id=42, body="Elute", ordering=2), ], ) for key, value in overrides.items(): setattr(tpl, key, value) return tpl def make_experiment(**overrides) -> FakeExperiment: """Standard experiment: step 9 (annotated) and step 10 (plain), both unfinished.""" exp = FakeExperiment( id=123, title="Plasmid prep", steps=[ FakeStep( id=9, body=f"Add ethanol to the tube\n{ANNOTATION_ETHANOL_FIFO}", ordering=1, ), FakeStep(id=10, body="Incubate 30 min", ordering=2), ], linked_items=[12], ) for key, value in overrides.items(): setattr(exp, key, value) return exp def make_item(**overrides) -> FakeItem: """Standard resource: ethanol with the marker comment and two containers.""" item = FakeItem( id=12, title="Absolute Ethanol", body="
Absolutely ethanol.
\n", containers=[ FakeContainer(sub_id=31, qty_stored=50.0, unit="mL"), FakeContainer(sub_id=32, qty_stored=10.0, unit="mL"), ], ) for key, value in overrides.items(): setattr(item, key, value) return item class FakeElabftwClient: """In-memory stand-in for the async eLabFTW adapter. Mirrors the ``ElabftwClient`` method surface. All mutations are recorded in ``self.calls`` as ``(method_name, kwargs)`` tuples so tests can assert exactly what changed (and that nothing changed when a workflow must abort). ``read_only=True`` makes every mutating method raise ``PermissionDeniedError`` like a canwrite=false API key would. """ def __init__( self, *, experiments: list[FakeExperiment] | None = None, items: list[FakeItem] | None = None, templates: list[FakeTemplate] | None = None, storage_units: list[StorageUnit] | None = None, read_only: bool = False, ) -> None: self.experiments = {e.id: e for e in (experiments or [])} self.items = {i.id: i for i in (items or [])} self.templates = {t.id: t for t in (templates or [])} self.storage_units = storage_units or [ StorageUnit(id=1, label="Bench fridge / Shelf 2") ] self.read_only = read_only self.calls: list[tuple[str, dict]] = [] self._next_comment_id = 500 self._next_experiment_id = 1000 # -- call recording ---------------------------------------------------- def _record(self, method: str, **kwargs) -> None: self.calls.append((method, kwargs)) def calls_of(self, method: str) -> list[dict]: return [kwargs for name, kwargs in self.calls if name == method] def _deny_if_read_only(self) -> None: if self.read_only: raise PermissionDeniedError( "The API key cannot modify eLabFTW; ask an admin for a writable key." ) # -- reads --------------------------------------------------------------- async def get_info(self) -> ElabftwInfo: self._record("get_info") return ElabftwInfo(elabftw_version="5.0.0", api_version="2") async def search_experiments(self, *, q=None, tags=None, limit=15, offset=0): self._record("search_experiments", q=q, tags=tags, limit=limit, offset=offset) hits = list(self.experiments.values()) if q: hits = [e for e in hits if q.lower() in e.title.lower()] if tags: hits = [e for e in hits if any(t in tags for t in getattr(e, "tags", []))] return [ ElabftwExperiment(id=e.id, title=e.title, status=e.status) for e in hits[offset : offset + limit] ] async def get_experiment(self, experiment_id: int) -> ElabftwExperiment: self._record("get_experiment", experiment_id=experiment_id) exp = self._entity("experiments", experiment_id) return ElabftwExperiment(id=exp.id, title=exp.title, status=exp.status) async def create_experiment(self, template_id: int, title=None): self._record("create_experiment", template_id=template_id, title=title) self._deny_if_read_only() if template_id not in self.templates: raise NotFoundError(f"Template {template_id} not found.") tpl = self.templates[template_id] new_id = self._next_experiment_id self._next_experiment_id += 1 exp = FakeExperiment( id=new_id, title=title or tpl.title, steps=[ FakeStep(id=s.id, body=s.body, ordering=s.ordering) for s in tpl.steps ], ) self.experiments[new_id] = exp return ElabftwExperiment(id=exp.id, title=exp.title, status=exp.status) async def get_steps(self, entity_type, entity_id): self._record("get_steps", entity_type=entity_type, entity_id=entity_id) steps = self._entity(entity_type, entity_id).steps return [ ElabftwStep(id=s.id, body=s.body, finished=s.finished, ordering=s.ordering) for s in steps ] async def finish_step(self, entity_type, entity_id, step_id): self._record( "finish_step", entity_type=entity_type, entity_id=entity_id, step_id=step_id ) self._deny_if_read_only() for step in self._entity(entity_type, entity_id).steps: if step.id == step_id: if step.finished: return ElabftwStep( id=step.id, body=step.body, finished=True, ordering=step.ordering ) step.finished = True return ElabftwStep( id=step.id, body=step.body, finished=True, ordering=step.ordering ) raise NotFoundError(f"Step {step_id} not found on {entity_type} {entity_id}.") async def list_comments(self, entity_type, entity_id): self._record("list_comments", entity_type=entity_type, entity_id=entity_id) return [ ElabftwComment(id=c.id, body=c.body, created_at=c.created_at) for c in self._entity(entity_type, entity_id).comments ] async def post_comment(self, entity_type, entity_id, body): self._record( "post_comment", entity_type=entity_type, entity_id=entity_id, body=body ) self._deny_if_read_only() comment = FakeComment(id=self._next_comment_id, body=body) self._next_comment_id += 1 self._entity(entity_type, entity_id).comments.append(comment) return ElabftwComment(id=comment.id, body=comment.body, created_at=None) async def search_items(self, *, q=None, limit=15, offset=0): self._record("search_items", q=q, limit=limit, offset=offset) hits = list(self.items.values()) if q: hits = [i for i in hits if q.lower() in i.title.lower()] return [self._item_model(i) for i in hits[offset : offset + limit]] async def get_item(self, item_id: int) -> ElabftwItem: self._record("get_item", item_id=item_id) return self._item_model(self._entity("items", item_id)) async def get_containers(self, entity_type, entity_id): self._record("get_containers", entity_type=entity_type, entity_id=entity_id) item = self._entity("items", entity_id) return [ ContainerLink(id=c.sub_id, qty_stored=c.qty_stored, unit=c.unit) for c in item.containers ] async def set_container_quantity(self, entity_type, entity_id, subitem_id, qty_stored): self._record( "set_container_quantity", entity_type=entity_type, entity_id=entity_id, subitem_id=subitem_id, qty_stored=qty_stored, ) self._deny_if_read_only() for container in self._entity("items", entity_id).containers: if container.sub_id == subitem_id: container.qty_stored = qty_stored return raise NotFoundError(f"Container {subitem_id} not found on item {entity_id}.") async def list_storage_units(self): self._record("list_storage_units") return list(self.storage_units) async def list_templates(self, *, q=None, limit=25, offset=0): self._record("list_templates", q=q, limit=limit, offset=offset) hits = list(self.templates.values()) if q: hits = [t for t in hits if q.lower() in t.title.lower()] return [ ElabftwTemplate(id=t.id, title=t.title, description=t.description, tags=t.tags) for t in hits[offset : offset + limit] ] async def get_template(self, template_id: int) -> ElabftwTemplate: self._record("get_template", template_id=template_id) if template_id not in self.templates: raise NotFoundError(f"Template {template_id} not found.") t = self.templates[template_id] return ElabftwTemplate(id=t.id, title=t.title, description=t.description, tags=t.tags) # -- helpers ------------------------------------------------------------- def _entity(self, entity_type, entity_id): if entity_type == "experiments": if entity_id not in self.experiments: raise NotFoundError(f"Experiment {entity_id} not found.") return self.experiments[entity_id] if entity_id not in self.items: raise NotFoundError(f"Item {entity_id} not found.") return self.items[entity_id] def _item_model(self, item: FakeItem) -> ElabftwItem: return ElabftwItem( id=item.id, title=item.title, body=item.body, **{"extra_fields": item.extra_fields} if item.extra_fields else {}, ) # --------------------------------------------------------------------------- # Stub resolver (resolver-level) # --------------------------------------------------------------------------- class StubResolver: """Dict-backed ``ResourceResolver`` stand-in for workflow tests.""" def __init__( self, mappings: dict[str, int] | None = None, *, fail_on: set[str] | None = None, options_by_key: dict[str, list[str]] | None = None, ) -> None: self.mappings = dict(mappings or {}) self.fail_on = fail_on or set() self.options_by_key = options_by_key or {} self.resolved: list[str] = [] async def resolve(self, resource_key: str) -> int: self.resolved.append(resource_key) if resource_key in self.fail_on or resource_key not in self.mappings: raise ClarificationNeededError( f"I don't know which resource '{resource_key}' refers to.", options=self.options_by_key.get(resource_key, []), ) return self.mappings[resource_key] async def find_candidates(self, resource_key: str) -> list[MappingProposal]: return [ MappingProposal(resource_key=resource_key, item_id=item_id) for item_id in sorted(self.mappings.values()) ] async def upsert_mapping(self, resource_key: str, item_id: int) -> MappingEntry: self.mappings[resource_key] = item_id return MappingEntry(resource_key=resource_key, item_id=item_id) async def list_mappings(self) -> list[MappingEntry]: return [ MappingEntry(resource_key=k, item_id=v) for k, v in sorted(self.mappings.items()) ] # --------------------------------------------------------------------------- # elabapi_python stubs (client construction seam, adapter tests only) # --------------------------------------------------------------------------- class StubElabapi: """Fake vendored elabapi_python client hub. Each generated ``*Api`` class is replaced by a thin proxy that forwards every method call to :meth:`handle`, which consults routes registered by the test. Unrouted calls fail loudly, so adapter tests only ever pass against the endpoints they explicitly define. """ API_CLASSES = ( "InfoApi", "InstanceApi", "ExperimentsApi", "ExperimentsTemplatesApi", "StepsApi", "CommentsApi", "ItemsApi", "ContainersApi", "StorageUnitsApi", "LinksToItemsApi", "UnfinishedStepsApi", "TodolistApi", ) def __init__(self) -> None: self.routes: dict[tuple[str, str], object] = {} self.calls: list[tuple[str, str, dict]] = [] self.thread_ids: list[tuple[str, int]] = [] def route(self, api: str, method: str, handler) -> None: self.routes[(api, method)] = handler async def handle(self, api: str, method: str, args: tuple, kwargs: dict): self.calls.append((api, method, kwargs)) self.thread_ids.append((f"{api}.{method}", __import__("threading").get_ident())) if (api, method) not in self.routes and (api, "*") not in self.routes: raise AssertionError(f"Unexpected elabapi call: {api}.{method}{args}{kwargs}") handler = self.routes.get((api, method)) or self.routes[(api, "*")] return handler(*args, **kwargs) def make_stub_elabapi_cls(hub: StubElabapi, api_name: str): class _ApiProxy: def __init__(self, api_client=None, *args, **kwargs): self.api_client = api_client def __getattr__(self, method: str): async def _call(*args, **kwargs): return await hub.handle(api_name, method, args, kwargs) _call.__name__ = method return _call _ApiProxy.__name__ = f"Stub{api_name}" return _ApiProxy def stub_elabapi(monkeypatch, hub: StubElabapi) -> StubElabapi: """Patch elabapi API classes (and Configuration/ApiClient) into mcp_server.elabftw.""" import elabapi_python.rest as elabapi_rest import mcp_server.elabftw as elabftw_module for api_name in hub.API_CLASSES: monkeypatch.setattr( elabftw_module, api_name, make_stub_elabapi_cls(hub, api_name), raising=False, ) class _StubConfiguration: def __init__(self, host=None, api_key=None, username=None, password=None, **kw): self.host = host self.api_key = api_key self.username = username self.password = password class _StubApiClient: def __init__(self, configuration=None, **kw): self.configuration = configuration def set_default_header(self, name, value): pass monkeypatch.setattr(elabftw_module, "Configuration", _StubConfiguration, raising=False) monkeypatch.setattr(elabftw_module, "ApiClient", _StubApiClient, raising=False) monkeypatch.setattr( elabftw_module, "ApiException", elabapi_rest.ApiException, raising=False ) return hub