Initial version
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,164 @@
|
||||
"""Shared fixtures for the LabVoice MCP server test suite.
|
||||
|
||||
Conventions pinned by this suite (adjust here if the implementation diverges):
|
||||
|
||||
1. Workflows and services receive their collaborators via constructor
|
||||
arguments (``client``, ``resolver``, ``journal``, ``settings``) exactly as
|
||||
declared in the scaffolds — tests inject ``FakeElabftwClient`` /
|
||||
``StubResolver`` and a real ``SagaJournal`` against a temp SQLite file.
|
||||
2. ``SagaJournal`` has an explicit ``connect()``/``close()`` lifecycle.
|
||||
3. The REST app and MCP tools resolve their read services through
|
||||
``mcp_server.server.build_services(settings)`` (the wiring container from
|
||||
the scaffold); tests monkeypatch that factory. The service container is a
|
||||
dict with the keys implied by the scaffold's build_services comment:
|
||||
``client``, ``resolver``, ``journal``, ``protocol``, ``inventory``,
|
||||
``setup``, ``templates``.
|
||||
4. MCP-level tests drive the real ``FastMCP`` server (from ``mcp`` 2.1.x)
|
||||
in-process via ``mcp.client.Client``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Make the vendored elabapi_python client importable for tests (it is vendored,
|
||||
# not installed). Its deps (six, urllib3, certifi) come from the dev group.
|
||||
_VENDOR = Path(__file__).resolve().parent.parent / "vendor" / "elabapi-python"
|
||||
if str(_VENDOR) not in sys.path:
|
||||
sys.path.insert(0, str(_VENDOR))
|
||||
|
||||
from mcp_server.config import Settings
|
||||
from mcp_server.journal import SagaJournal
|
||||
from mcp_server.resolve import ResourceResolver
|
||||
from mcp_server.templates import TemplateService
|
||||
from mcp_server.workflows.inventory import InventoryWorkflow
|
||||
from mcp_server.workflows.protocol import ProtocolWorkflow
|
||||
from mcp_server.workflows.setup import SetupWorkflow
|
||||
|
||||
from fakes import (
|
||||
FakeElabftwClient,
|
||||
StubResolver,
|
||||
make_experiment,
|
||||
make_item,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path, monkeypatch) -> Settings:
|
||||
"""Isolated settings: temp DB, no env/.env interference, distinctive API key."""
|
||||
for var in (
|
||||
"ELABFTW_URL",
|
||||
"ELABFTW_API_KEY",
|
||||
"LABVOICE_DB_PATH",
|
||||
"LABVOICE_REST_TOKEN",
|
||||
"LABVOICE_TEMPLATE_DESC_LIMIT",
|
||||
"LABVOICE_EMBED_MODEL",
|
||||
"LABVOICE_UNIT_WHITELIST",
|
||||
"LABVOICE_EXPIRY_FIELD",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
elabftw_url="https://eln.example.org",
|
||||
elabftw_api_key="labvoice-secret-key-do-not-leak",
|
||||
elabftw_timeout=5.0,
|
||||
elabftw_retries=0,
|
||||
labvoice_db_path=tmp_path / "journal.sqlite",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> FakeElabftwClient:
|
||||
"""Standard scenario: experiment 123 (steps 9, 10) and ethanol item 12."""
|
||||
return FakeElabftwClient(experiments=[make_experiment()], items=[make_item()])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver_stub() -> StubResolver:
|
||||
"""ethanol_absolute → item 12; other keys raise clarification."""
|
||||
return StubResolver(mappings={"ethanol_absolute": 12})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def journal(settings) -> SagaJournal:
|
||||
journal = SagaJournal(settings.labvoice_db_path)
|
||||
await journal.connect()
|
||||
yield journal
|
||||
await journal.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def protocol(client, resolver_stub, journal, settings) -> ProtocolWorkflow:
|
||||
return ProtocolWorkflow(
|
||||
client=client, resolver=resolver_stub, journal=journal, settings=settings
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inventory(client, resolver_stub, journal, settings) -> InventoryWorkflow:
|
||||
return InventoryWorkflow(
|
||||
client=client, resolver=resolver_stub, journal=journal, settings=settings
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(client, resolver_stub, settings) -> SetupWorkflow:
|
||||
return SetupWorkflow(client=client, resolver=resolver_stub, settings=settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def templates_service(client, settings) -> TemplateService:
|
||||
return TemplateService(client=client, settings=settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def real_resolver(settings):
|
||||
"""Real ResourceResolver over a temp DB — resolver/mapping-store tests only."""
|
||||
resolver = ResourceResolver(settings)
|
||||
if hasattr(resolver, "connect"):
|
||||
await resolver.connect()
|
||||
yield resolver
|
||||
if hasattr(resolver, "close"):
|
||||
await resolver.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service_container(client, resolver_stub, journal, settings, templates_service) -> dict:
|
||||
"""Fake ``build_services`` container (keys per scaffold comment)."""
|
||||
return {
|
||||
"client": client,
|
||||
"resolver": resolver_stub,
|
||||
"journal": journal,
|
||||
"protocol": ProtocolWorkflow(
|
||||
client=client, resolver=resolver_stub, journal=journal, settings=settings
|
||||
),
|
||||
"inventory": InventoryWorkflow(
|
||||
client=client, resolver=resolver_stub, journal=journal, settings=settings
|
||||
),
|
||||
"setup": SetupWorkflow(client=client, resolver=resolver_stub, settings=settings),
|
||||
"templates": templates_service,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_build_services(monkeypatch, service_container):
|
||||
"""Intercept ``build_services`` wherever a transport layer imports it."""
|
||||
|
||||
def _patch():
|
||||
import mcp_server.rest as rest_module
|
||||
import mcp_server.server as server_module
|
||||
import mcp_server.tools as tools_module
|
||||
|
||||
for module in (server_module, rest_module, tools_module):
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"build_services",
|
||||
lambda _settings, _container=service_container: _container,
|
||||
raising=False,
|
||||
)
|
||||
return service_container
|
||||
|
||||
return _patch
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
"""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 = (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
|
||||
' "unit": "mL", "allocation": "fifo"}]}\n'
|
||||
"-->"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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="<p>Absolutely ethanol.</p>\n<!-- labvoice:resource-key=ethanol_absolute -->",
|
||||
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
|
||||
@@ -0,0 +1,176 @@
|
||||
"""allocate.py — unit whitelist/conversion + allocation policies (plan §4, §7).
|
||||
|
||||
Tested conversion pairs per the plan: mL↔L and the mass chain μg↔mg↔g↔kg;
|
||||
anything outside the whitelist or across dimensions must be a clarification,
|
||||
never a guess.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.allocate import are_compatible, choose_containers, convert
|
||||
from mcp_server.errors import ClarificationNeededError, InsufficientStockError
|
||||
from mcp_server.models import AllocationPolicy, ContainerStock
|
||||
|
||||
|
||||
def stock(cid: int, available: float, unit: str = "mL") -> ContainerStock:
|
||||
return ContainerStock(container_id=cid, amount=f"{available} {unit}", available=available, unit=unit)
|
||||
|
||||
|
||||
# --- unit compatibility -----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("a", "b"),
|
||||
[("mL", "L"), ("L", "mL"), ("mg", "g"), ("g", "kg"), ("μg", "mg"), ("ea", "ea")],
|
||||
)
|
||||
def test_compatible_within_dimension(a, b):
|
||||
assert are_compatible(a, b)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("a", "b"),
|
||||
[("mL", "mg"), ("L", "ea"), ("ea", "mL"), ("g", "μL")],
|
||||
)
|
||||
def test_incompatible_across_dimensions(a, b):
|
||||
assert not are_compatible(a, b)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unit", ["fl-oz", "cups", "", "mmol"])
|
||||
def test_unknown_units_are_never_compatible(unit):
|
||||
assert not are_compatible(unit, "mL")
|
||||
assert not are_compatible("mL", unit)
|
||||
|
||||
|
||||
# --- conversion -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_volume_conversions():
|
||||
assert convert(1.0, "L", "mL") == pytest.approx(1000.0)
|
||||
assert convert(500.0, "mL", "L") == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_mass_conversions():
|
||||
assert convert(1.0, "g", "mg") == pytest.approx(1000.0)
|
||||
assert convert(1.0, "kg", "g") == pytest.approx(1000.0)
|
||||
assert convert(1000.0, "mg", "g") == pytest.approx(1.0)
|
||||
assert convert(1000.0, "μg", "mg") == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_count_is_identity():
|
||||
assert convert(3.0, "ea", "ea") == 3.0
|
||||
|
||||
|
||||
def test_cross_dimension_conversion_is_a_clarification():
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
convert(1.0, "mL", "mg")
|
||||
|
||||
|
||||
def test_unknown_unit_conversion_is_a_clarification():
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
convert(1.0, "fl-oz", "mL")
|
||||
|
||||
|
||||
# --- fifo allocation --------------------------------------------------------
|
||||
|
||||
|
||||
def test_fifo_takes_from_lowest_container_id_with_stock():
|
||||
allocations = choose_containers(
|
||||
[stock(45, 5.0), stock(31, 50.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
|
||||
)
|
||||
assert len(allocations) == 1
|
||||
assert allocations[0].container_id == 31
|
||||
assert allocations[0].take == pytest.approx(2.0)
|
||||
assert allocations[0].remaining == "48.0 mL"
|
||||
|
||||
|
||||
def test_fifo_splits_across_containers_when_first_is_short():
|
||||
allocations = choose_containers(
|
||||
[stock(31, 1.5), stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
|
||||
)
|
||||
assert [a.container_id for a in allocations] == [31, 32]
|
||||
assert allocations[0].take == pytest.approx(1.5)
|
||||
assert allocations[1].take == pytest.approx(0.5)
|
||||
assert allocations[1].remaining == "9.5 mL"
|
||||
|
||||
|
||||
def test_fifo_skips_empty_containers():
|
||||
allocations = choose_containers(
|
||||
[stock(31, 0.0), stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
|
||||
)
|
||||
assert [a.container_id for a in allocations] == [32]
|
||||
|
||||
|
||||
def test_fifo_exact_fit_leaves_container_empty():
|
||||
allocations = choose_containers(
|
||||
[stock(31, 2.0)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
|
||||
)
|
||||
assert allocations[0].remaining == "0.0 mL"
|
||||
|
||||
|
||||
def test_insufficient_stock_raises_with_shortfall():
|
||||
with pytest.raises(InsufficientStockError) as excinfo:
|
||||
choose_containers(
|
||||
[stock(31, 1.0), stock(32, 0.5)], required=2.0, unit="mL", policy=AllocationPolicy.FIFO
|
||||
)
|
||||
message = str(excinfo.value)
|
||||
assert "0.5" in message # 1.5 available vs 2.0 required → 0.5 short
|
||||
|
||||
|
||||
def test_insufficient_stock_with_no_containers_at_all():
|
||||
with pytest.raises(InsufficientStockError):
|
||||
choose_containers([], required=2.0, unit="mL", policy=AllocationPolicy.FIFO)
|
||||
|
||||
|
||||
# --- unit conversion during allocation --------------------------------------
|
||||
|
||||
|
||||
def test_allocation_converts_between_compatible_units():
|
||||
"""Required 0.5 L against a container stocked in mL."""
|
||||
allocations = choose_containers(
|
||||
[stock(31, 1000.0, "mL")], required=0.5, unit="L", policy=AllocationPolicy.FIFO
|
||||
)
|
||||
assert allocations[0].take == pytest.approx(500.0)
|
||||
assert allocations[0].remaining == "500.0 mL"
|
||||
|
||||
|
||||
def test_allocation_rejects_incompatible_container_units():
|
||||
containers = [stock(31, 10.0, "mg")]
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
choose_containers(containers, required=2.0, unit="mL", policy=AllocationPolicy.FIFO)
|
||||
|
||||
|
||||
# --- specific allocation ----------------------------------------------------
|
||||
|
||||
|
||||
def test_specific_takes_only_from_the_listed_container():
|
||||
"""The caller pre-filters to the hinted container (consumable.container_id)."""
|
||||
allocations = choose_containers(
|
||||
[stock(32, 10.0)], required=2.0, unit="mL", policy=AllocationPolicy.SPECIFIC
|
||||
)
|
||||
assert [a.container_id for a in allocations] == [32]
|
||||
|
||||
|
||||
def test_specific_with_unavailable_container_is_a_clarification():
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
choose_containers([], required=2.0, unit="mL", policy=AllocationPolicy.SPECIFIC)
|
||||
|
||||
|
||||
# --- nearest expiry (deferred per plan §15.2, kept as executable spec) ------
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="nearest_expiry deferred per plan §15.2; expiry_field config pending")
|
||||
def test_nearest_expiry_picks_soonest_expiry_first():
|
||||
soon = ContainerStock.model_construct(
|
||||
container_id=44, amount="5 mL", available=5.0, unit="mL", expires="2026-01-01"
|
||||
)
|
||||
later = ContainerStock.model_construct(
|
||||
container_id=31, amount="5 mL", available=5.0, unit="mL", expires="2027-01-01"
|
||||
)
|
||||
allocations = choose_containers(
|
||||
[later, soon],
|
||||
required=2.0,
|
||||
unit="mL",
|
||||
policy=AllocationPolicy.NEAREST_EXPIRY,
|
||||
expiry_field="Expiry date",
|
||||
)
|
||||
assert allocations[0].container_id == 44
|
||||
@@ -0,0 +1,159 @@
|
||||
"""annotate.py — labvoice:v1 parser rules (plan §4, §13)."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.annotate import extract_annotation_block, parse_annotation, validate_annotation
|
||||
from mcp_server.errors import AnnotationError
|
||||
from mcp_server.models import AllocationPolicy
|
||||
|
||||
|
||||
def valid_block(consumables: list[dict], **extra) -> str:
|
||||
payload = {"consumables": consumables, **extra}
|
||||
return f"<!-- labvoice:v1\n{json.dumps(payload)}\n-->"
|
||||
|
||||
|
||||
ETHANOL = {
|
||||
"resource_key": "ethanol_absolute",
|
||||
"quantity": 2.0,
|
||||
"unit": "mL",
|
||||
"allocation": "fifo",
|
||||
}
|
||||
|
||||
|
||||
# --- extraction -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_returns_block_when_present():
|
||||
body = f"Add ethanol to the tube\n{valid_block([ETHANOL])}"
|
||||
block = extract_annotation_block(body)
|
||||
assert block is not None
|
||||
assert "labvoice:v1" in block
|
||||
assert "ethanol_absolute" in block
|
||||
|
||||
|
||||
def test_extract_returns_none_when_no_annotation():
|
||||
assert extract_annotation_block("Just a normal step.") is None
|
||||
assert extract_annotation_block("") is None
|
||||
|
||||
|
||||
def test_extract_ignores_other_html_comments():
|
||||
body = "Step text\n<!-- some editor note -->\nmore text"
|
||||
assert extract_annotation_block(body) is None
|
||||
|
||||
|
||||
def test_extract_takes_the_last_valid_block():
|
||||
first = valid_block([{**ETHANOL, "quantity": 1.0}])
|
||||
second = valid_block([{**ETHANOL, "quantity": 2.0}])
|
||||
body = f"Step text\n{first}\n{second}"
|
||||
block = extract_annotation_block(body)
|
||||
assert json.loads(block.split("labvoice:v1")[1].rsplit("-->")[0])["consumables"][0][
|
||||
"quantity"
|
||||
] == 2.0
|
||||
|
||||
|
||||
def test_extract_tolerates_multiline_and_whitespace():
|
||||
body = "text\n<!-- labvoice:v1\n\n {\"consumables\": []} \n -->"
|
||||
assert extract_annotation_block(body) is not None
|
||||
|
||||
|
||||
# --- parsing ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_valid_annotation():
|
||||
body = f"Add ethanol\n{valid_block([ETHANOL])}"
|
||||
annotation = parse_annotation(body)
|
||||
assert annotation is not None
|
||||
assert len(annotation.consumables) == 1
|
||||
consumable = annotation.consumables[0]
|
||||
assert consumable.resource_key == "ethanol_absolute"
|
||||
assert consumable.quantity == 2.0
|
||||
assert consumable.unit == "mL"
|
||||
assert consumable.allocation is AllocationPolicy.FIFO
|
||||
assert annotation.prompt_quantity is False
|
||||
|
||||
|
||||
def test_parse_returns_none_without_annotation():
|
||||
assert parse_annotation("Plain step body.") is None
|
||||
|
||||
|
||||
def test_parse_defaults_allocation_to_fifo():
|
||||
body = f"x\n{valid_block([{k: ETHANOL[k] for k in ('resource_key', 'quantity', 'unit')}])}"
|
||||
annotation = parse_annotation(body)
|
||||
assert annotation.consumables[0].allocation is AllocationPolicy.FIFO
|
||||
|
||||
|
||||
def test_parse_prompt_quantity_annotation():
|
||||
body = f"x\n{valid_block([{'resource_key': 'buffer', 'quantity': None, 'unit': 'mL'}], prompt_quantity=True)}"
|
||||
annotation = parse_annotation(body)
|
||||
assert annotation.prompt_quantity is True
|
||||
assert annotation.consumables[0].quantity is None
|
||||
|
||||
|
||||
def test_parse_malformed_json_raises_annotation_error():
|
||||
body = "text\n<!-- labvoice:v1\n{not json at all\n-->"
|
||||
with pytest.raises(AnnotationError):
|
||||
parse_annotation(body)
|
||||
|
||||
|
||||
def test_parse_schema_violation_missing_required_field():
|
||||
body = f"x\n{valid_block([{'resource_key': 'ethanol_absolute', 'unit': 'mL'}])}"
|
||||
with pytest.raises(AnnotationError):
|
||||
parse_annotation(body)
|
||||
|
||||
|
||||
def test_parse_schema_violation_unknown_allocation():
|
||||
body = f"x\n{valid_block([{**ETHANOL, 'allocation': 'magical'}])}"
|
||||
with pytest.raises(AnnotationError):
|
||||
parse_annotation(body)
|
||||
|
||||
|
||||
def test_parse_schema_violation_negative_quantity():
|
||||
body = f"x\n{valid_block([{**ETHANOL, 'quantity': -2.0}])}"
|
||||
with pytest.raises(AnnotationError):
|
||||
parse_annotation(body)
|
||||
|
||||
|
||||
def test_parse_schema_violation_null_quantity_requires_prompt_quantity():
|
||||
body = f"x\n{valid_block([{'resource_key': 'buffer', 'quantity': None, 'unit': 'mL'}])}"
|
||||
with pytest.raises(AnnotationError):
|
||||
parse_annotation(body)
|
||||
|
||||
|
||||
def test_parse_schema_violation_container_id_outside_specific():
|
||||
body = f"x\n{valid_block([{**ETHANOL, 'container_id': 31}])}"
|
||||
with pytest.raises(AnnotationError):
|
||||
parse_annotation(body)
|
||||
|
||||
|
||||
def test_parse_allows_container_id_with_specific_allocation():
|
||||
body = f"x\n{valid_block([{**ETHANOL, 'allocation': 'specific', 'container_id': 31}])}"
|
||||
annotation = parse_annotation(body)
|
||||
assert annotation.consumables[0].container_id == 31
|
||||
|
||||
|
||||
# --- validation -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_clean_annotation_returns_no_violations():
|
||||
body = f"Add ethanol\n{valid_block([ETHANOL])}"
|
||||
assert validate_annotation(body) == []
|
||||
|
||||
|
||||
def test_validate_body_without_annotation_has_nothing_to_check():
|
||||
assert validate_annotation("No annotations here.") == []
|
||||
|
||||
|
||||
def test_validate_malformed_annotation_reports_violation():
|
||||
body = "text\n<!-- labvoice:v1\n{broken\n-->"
|
||||
issues = validate_annotation(body)
|
||||
assert issues, "malformed annotation must be reported, never silently ignored"
|
||||
assert any("json" in issue.lower() or "parse" in issue.lower() for issue in issues)
|
||||
|
||||
|
||||
def test_validate_missing_required_field_is_reported():
|
||||
body = f"x\n{valid_block([{'resource_key': 'ethanol_absolute'}])}"
|
||||
issues = validate_annotation(body)
|
||||
assert issues
|
||||
assert any("unit" in issue.lower() or "quantity" in issue.lower() for issue in issues)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""config.py — env-driven settings (plan §11)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mcp_server.config import Settings, get_settings
|
||||
|
||||
|
||||
def test_defaults():
|
||||
settings = Settings(_env_file=None)
|
||||
assert settings.elabftw_timeout == 10.0
|
||||
assert settings.elabftw_retries == 2
|
||||
assert settings.labvoice_db_path == Path("~/.labvoice/journal.sqlite")
|
||||
assert settings.labvoice_template_desc_limit == 200
|
||||
assert settings.labvoice_rest_token is None
|
||||
assert settings.labvoice_embed_model is None
|
||||
|
||||
|
||||
def test_default_unit_whitelist_matches_plan():
|
||||
settings = Settings(_env_file=None)
|
||||
assert "μL" in settings.unit_whitelist
|
||||
assert "mL" in settings.unit_whitelist
|
||||
assert "ea" in settings.unit_whitelist
|
||||
|
||||
|
||||
def test_unit_whitelist_parsing_ignores_blanks_and_spaces():
|
||||
settings = Settings(_env_file=None, labvoice_unit_whitelist=" mL , L , , ea ")
|
||||
assert settings.unit_whitelist == ("mL", "L", "ea")
|
||||
|
||||
|
||||
def test_api_base_url_strips_trailing_slash():
|
||||
settings = Settings(_env_file=None, elabftw_url="https://eln.example.org/")
|
||||
assert settings.api_base_url == "https://eln.example.org/api/v2"
|
||||
|
||||
|
||||
def test_env_overrides(monkeypatch):
|
||||
monkeypatch.setenv("ELABFTW_URL", "https://eln2.example.org")
|
||||
monkeypatch.setenv("ELABFTW_TIMEOUT", "3.5")
|
||||
monkeypatch.setenv("LABVOICE_DB_PATH", "/tmp/other.sqlite")
|
||||
settings = Settings(_env_file=None)
|
||||
assert settings.elabftw_url == "https://eln2.example.org"
|
||||
assert settings.elabftw_timeout == 3.5
|
||||
assert settings.labvoice_db_path == Path("/tmp/other.sqlite")
|
||||
|
||||
|
||||
def test_get_settings_is_cached(monkeypatch):
|
||||
monkeypatch.setenv("ELABFTW_URL", "https://a.example.org")
|
||||
first = get_settings()
|
||||
monkeypatch.setenv("ELABFTW_URL", "https://b.example.org")
|
||||
assert get_settings() is first
|
||||
get_settings.cache_clear()
|
||||
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
"""errors.py — typed errors with voice-friendly messages (plan §12)."""
|
||||
|
||||
from mcp_server.errors import (
|
||||
AnnotationError,
|
||||
ApiError,
|
||||
AuthError,
|
||||
ClarificationNeededError,
|
||||
ErrorKind,
|
||||
InsufficientStockError,
|
||||
LabVoiceError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
)
|
||||
|
||||
|
||||
def test_every_error_kind_maps_to_its_class():
|
||||
assert AuthError.kind is ErrorKind.AUTH
|
||||
assert PermissionDeniedError.kind is ErrorKind.PERMISSION
|
||||
assert NotFoundError.kind is ErrorKind.NOT_FOUND
|
||||
assert ApiError.kind is ErrorKind.API
|
||||
assert AnnotationError.kind is ErrorKind.ANNOTATION
|
||||
assert ClarificationNeededError.kind is ErrorKind.CLARIFICATION
|
||||
assert InsufficientStockError.kind is ErrorKind.INSUFFICIENT_STOCK
|
||||
assert LabVoiceError.kind is ErrorKind.API
|
||||
|
||||
|
||||
def test_kind_values_are_the_wire_strings():
|
||||
assert ErrorKind.AUTH == "auth_error"
|
||||
assert ErrorKind.PERMISSION == "permission_error"
|
||||
assert ErrorKind.NOT_FOUND == "not_found"
|
||||
assert ErrorKind.CLARIFICATION == "clarification"
|
||||
assert ErrorKind.INSUFFICIENT_STOCK == "insufficient_stock"
|
||||
|
||||
|
||||
def test_message_and_detail_are_preserved():
|
||||
err = NotFoundError("Experiment 123 not found.", detail="GET /experiments/123")
|
||||
assert str(err) == "Experiment 123 not found."
|
||||
assert err.message == "Experiment 123 not found."
|
||||
assert err.detail == "GET /experiments/123"
|
||||
|
||||
|
||||
def test_all_labvoice_errors_share_the_base():
|
||||
for cls in (
|
||||
AuthError,
|
||||
PermissionDeniedError,
|
||||
NotFoundError,
|
||||
ApiError,
|
||||
AnnotationError,
|
||||
ClarificationNeededError,
|
||||
InsufficientStockError,
|
||||
):
|
||||
assert issubclass(cls, LabVoiceError)
|
||||
|
||||
|
||||
def test_clarification_options_default_empty_and_are_preserved():
|
||||
err = ClarificationNeededError("Which ethanol?")
|
||||
assert err.options == []
|
||||
err = ClarificationNeededError("Which ethanol?", options=["Absolute Ethanol", "96%"])
|
||||
assert err.options == ["Absolute Ethanol", "96%"]
|
||||
|
||||
|
||||
def test_error_messages_are_voice_friendly_strings():
|
||||
err = PermissionDeniedError("This API key is read-only; I can't finish the step.")
|
||||
assert isinstance(err.message, str) and err.message
|
||||
@@ -0,0 +1,135 @@
|
||||
"""journal.py — SQLite saga journal: idempotency, statuses, durability (plan §10)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.journal import ActionStatus, OperationStatus, SagaJournal
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fresh_journal(tmp_path):
|
||||
journal = SagaJournal(tmp_path / "saga.sqlite")
|
||||
await journal.connect()
|
||||
yield journal
|
||||
await journal.close()
|
||||
|
||||
|
||||
async def test_connect_is_idempotent_safe_for_new_db(fresh_journal):
|
||||
record = await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
assert record.status is OperationStatus.PLANNED
|
||||
assert record.kind == "complete_step"
|
||||
assert record.actions == []
|
||||
|
||||
|
||||
async def test_begin_generates_operation_id_when_none_given(fresh_journal):
|
||||
record = await fresh_journal.begin_operation("complete_step")
|
||||
assert record.operation_id
|
||||
|
||||
|
||||
async def test_record_and_mark_actions(fresh_journal):
|
||||
await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
action = await fresh_journal.record_action(
|
||||
"op-1", "decrement_container", {"item_id": 12, "subitem_id": 31, "qty_stored": 48.0}
|
||||
)
|
||||
assert action.status is ActionStatus.PLANNED
|
||||
assert action.payload["subitem_id"] == 31
|
||||
|
||||
marked = await fresh_journal.mark_action(
|
||||
"op-1", action.id, ActionStatus.DONE, response={"ok": True}
|
||||
)
|
||||
assert marked.status is ActionStatus.DONE
|
||||
assert marked.response == {"ok": True}
|
||||
|
||||
|
||||
async def test_get_operation_returns_actions_in_order(fresh_journal):
|
||||
await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
first = await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
|
||||
second = await fresh_journal.record_action("op-1", "finish_step", {"step_id": 9})
|
||||
third = await fresh_journal.record_action("op-1", "post_comment", {"body": "done"})
|
||||
|
||||
record = await fresh_journal.get_operation("op-1")
|
||||
assert [a.id for a in record.actions] == [first.id, second.id, third.id]
|
||||
assert [a.action for a in record.actions] == [
|
||||
"decrement_container",
|
||||
"finish_step",
|
||||
"post_comment",
|
||||
]
|
||||
|
||||
|
||||
async def test_has_completed_only_after_finish(fresh_journal):
|
||||
await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
action = await fresh_journal.record_action("op-1", "decrement_container", {})
|
||||
await fresh_journal.mark_action("op-1", action.id, ActionStatus.DONE)
|
||||
assert await fresh_journal.has_completed("op-1") is False
|
||||
|
||||
await fresh_journal.finish_operation("op-1", OperationStatus.COMPLETED)
|
||||
assert await fresh_journal.has_completed("op-1") is True
|
||||
|
||||
|
||||
async def test_begin_with_completed_operation_id_is_idempotent_replay(fresh_journal):
|
||||
"""Plan §13: double execution of the same operation_id is idempotent."""
|
||||
await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
|
||||
await fresh_journal.finish_operation("op-1", OperationStatus.COMPLETED)
|
||||
|
||||
replayed = await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
assert replayed.status is OperationStatus.COMPLETED
|
||||
assert len(replayed.actions) == 1
|
||||
record = await fresh_journal.get_operation("op-1")
|
||||
assert len(record.actions) == 1, "replay must not append new actions"
|
||||
|
||||
|
||||
async def test_begin_with_inprogress_operation_id_resumes_it(fresh_journal):
|
||||
await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
await fresh_journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
|
||||
|
||||
resumed = await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
assert resumed.status is OperationStatus.PLANNED
|
||||
assert len(resumed.actions) == 1
|
||||
|
||||
|
||||
async def test_failed_and_compensated_statuses_persist(fresh_journal):
|
||||
await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
action = await fresh_journal.record_action("op-1", "finish_step", {"step_id": 9})
|
||||
await fresh_journal.mark_action("op-1", action.id, ActionStatus.FAILED)
|
||||
await fresh_journal.finish_operation("op-1", OperationStatus.COMPENSATED)
|
||||
|
||||
record = await fresh_journal.get_operation("op-1")
|
||||
assert record.status is OperationStatus.COMPENSATED
|
||||
assert record.actions[0].status is ActionStatus.FAILED
|
||||
assert await fresh_journal.has_completed("op-1") is False
|
||||
|
||||
|
||||
async def test_partial_failure_status_roundtrip(fresh_journal):
|
||||
"""Plan §10: compensation failure ⇒ partial_failure in the journal."""
|
||||
await fresh_journal.begin_operation("complete_step", "op-1")
|
||||
await fresh_journal.finish_operation("op-1", OperationStatus.PARTIAL_FAILURE)
|
||||
record = await fresh_journal.get_operation("op-1")
|
||||
assert record.status is OperationStatus.PARTIAL_FAILURE
|
||||
|
||||
|
||||
async def test_journal_persists_across_reconnect(tmp_path):
|
||||
"""The journal doubles as the audit log — it must survive restarts."""
|
||||
path = tmp_path / "saga.sqlite"
|
||||
journal = SagaJournal(path)
|
||||
await journal.connect()
|
||||
await journal.begin_operation("complete_step", "op-1")
|
||||
action = await journal.record_action("op-1", "decrement_container", {"subitem_id": 31})
|
||||
await journal.mark_action("op-1", action.id, ActionStatus.DONE, response={"qty": 48.0})
|
||||
await journal.finish_operation("op-1", OperationStatus.COMPLETED)
|
||||
await journal.close()
|
||||
|
||||
reopened = SagaJournal(path)
|
||||
await reopened.connect()
|
||||
try:
|
||||
record = await reopened.get_operation("op-1")
|
||||
assert record.kind == "complete_step"
|
||||
assert record.status is OperationStatus.COMPLETED
|
||||
assert record.actions[0].response == {"qty": 48.0}
|
||||
assert await reopened.has_completed("op-1") is True
|
||||
finally:
|
||||
await reopened.close()
|
||||
|
||||
|
||||
async def test_get_operation_of_unknown_id_raises(fresh_journal):
|
||||
with pytest.raises(Exception):
|
||||
await fresh_journal.get_operation("nope")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""models.py — compact result shapes (plan §6: ids, titles, quantities, statuses)."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from mcp_server.models import (
|
||||
AllocationPolicy,
|
||||
CompleteStepResult,
|
||||
Consumable,
|
||||
HandoffContext,
|
||||
NextStep,
|
||||
ProtocolAnnotation,
|
||||
StepInfo,
|
||||
)
|
||||
|
||||
|
||||
def test_consumable_defaults_to_fifo_and_required():
|
||||
consumable = Consumable(resource_key="ethanol_absolute", quantity=2.0, unit="mL")
|
||||
assert consumable.allocation is AllocationPolicy.FIFO
|
||||
assert consumable.optional is False
|
||||
assert consumable.container_id is None
|
||||
|
||||
|
||||
def test_consumable_quantity_may_be_null_for_prompted_steps():
|
||||
consumable = Consumable(resource_key="x", quantity=None, unit="mL")
|
||||
assert consumable.quantity is None
|
||||
|
||||
|
||||
def test_consumable_rejects_missing_required_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
Consumable(resource_key="x") # quantity and unit missing
|
||||
|
||||
|
||||
def test_annotation_defaults():
|
||||
annotation = ProtocolAnnotation()
|
||||
assert annotation.consumables == []
|
||||
assert annotation.prompt_quantity is False
|
||||
|
||||
|
||||
def test_step_info_defaults_unfinished():
|
||||
step = StepInfo(id=9, body="Add ethanol")
|
||||
assert step.finished is False
|
||||
assert step.position is None
|
||||
|
||||
|
||||
def test_complete_step_result_status_is_restricted():
|
||||
for status in ("completed", "protocol_complete", "reverted", "partial_failure"):
|
||||
CompleteStepResult(
|
||||
experiment_id=123,
|
||||
status=status,
|
||||
step=StepInfo(id=9, body="b"),
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
CompleteStepResult(
|
||||
experiment_id=123,
|
||||
status="exploded",
|
||||
step=StepInfo(id=9, body="b"),
|
||||
)
|
||||
|
||||
|
||||
def test_next_step_carries_consumables_and_stock():
|
||||
next_step = NextStep(experiment_id=123, step=StepInfo(id=9, body="Add ethanol"))
|
||||
assert next_step.consumables == []
|
||||
assert next_step.stock == []
|
||||
|
||||
|
||||
def test_handoff_context_is_id_only_and_rest_optional():
|
||||
"""Plan §8: the app forwards only ids; device/location are optional extras."""
|
||||
handoff = HandoffContext(experiment_id=123, user_id=2)
|
||||
assert handoff.step_id is None
|
||||
assert handoff.device_id is None
|
||||
assert handoff.location_id is None
|
||||
@@ -0,0 +1,89 @@
|
||||
"""resolve.py — resource_key → item id precedence and ambiguity (plan §5).
|
||||
|
||||
Precedence: local mapping store (authoritative at execution) → resource marker
|
||||
→ (setup-time-only matchers/title, never silently applied at runtime).
|
||||
Ambiguous or missing ⇒ ClarificationNeededError listing candidates.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fakes import make_item
|
||||
from mcp_server.errors import ClarificationNeededError
|
||||
|
||||
|
||||
async def test_resolve_via_marker_when_store_is_empty(client, real_resolver):
|
||||
"""Marker comment in the resource body is discovered without any mapping."""
|
||||
assert await real_resolver.resolve("ethanol_absolute") == 12
|
||||
|
||||
|
||||
async def test_mapping_store_is_authoritative_over_marker(client, real_resolver):
|
||||
await real_resolver.upsert_mapping("ethanol_absolute", 12)
|
||||
# Even if another item also carries the marker, the store binding wins.
|
||||
client.items[13] = make_item(id=13, title="Ethanol bottle (old)", body="<!-- labvoice:resource-key=ethanol_absolute -->")
|
||||
assert await real_resolver.resolve("ethanol_absolute") == 12
|
||||
|
||||
|
||||
async def test_resolve_unknown_key_raises_clarification(client, real_resolver):
|
||||
with pytest.raises(ClarificationNeededError) as excinfo:
|
||||
await real_resolver.resolve("sodium_azide")
|
||||
assert "sodium_azide" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_resolve_ambiguous_marker_raises_clarification_with_candidates(client, real_resolver):
|
||||
client.items[13] = make_item(id=13, title="Ethanol bottle 2", body="<!-- labvoice:resource-key=ethanol_absolute -->")
|
||||
with pytest.raises(ClarificationNeededError) as excinfo:
|
||||
await real_resolver.resolve("ethanol_absolute")
|
||||
assert excinfo.value.options, "clarification must list candidate resources"
|
||||
|
||||
|
||||
async def test_upsert_mapping_is_idempotent(client, real_resolver):
|
||||
await real_resolver.upsert_mapping("ethanol_absolute", 12)
|
||||
await real_resolver.upsert_mapping("ethanol_absolute", 12)
|
||||
mappings = await real_resolver.list_mappings()
|
||||
assert [m.item_id for m in mappings if m.resource_key == "ethanol_absolute"] == [12]
|
||||
|
||||
|
||||
async def test_upsert_mapping_updates_existing_binding(client, real_resolver):
|
||||
await real_resolver.upsert_mapping("ethanol_absolute", 12)
|
||||
await real_resolver.upsert_mapping("ethanol_absolute", 99)
|
||||
assert await real_resolver.resolve("ethanol_absolute") == 99
|
||||
|
||||
|
||||
async def test_list_mappings_empty_store(real_resolver):
|
||||
assert await real_resolver.list_mappings() == []
|
||||
|
||||
|
||||
async def test_export_mapping_produces_portable_json(client, real_resolver):
|
||||
await real_resolver.upsert_mapping("ethanol_absolute", 12)
|
||||
data = await real_resolver.export_mapping()
|
||||
assert "ethanol_absolute" in data
|
||||
entry = data["ethanol_absolute"]
|
||||
assert entry.get("title") in (None, "Absolute Ethanol")
|
||||
|
||||
|
||||
async def test_import_mapping_returns_proposals(client, real_resolver):
|
||||
data = {"ethanol_absolute": {"cas": "64-17-5", "title": "Absolute Ethanol", "unit": "mL"}}
|
||||
proposals = await real_resolver.import_mapping(data)
|
||||
assert proposals
|
||||
assert proposals[0].resource_key == "ethanol_absolute"
|
||||
|
||||
|
||||
async def test_find_candidates_finds_marker_match(client, real_resolver):
|
||||
proposals = await real_resolver.find_candidates("ethanol_absolute")
|
||||
assert any(p.item_id == 12 for p in proposals)
|
||||
|
||||
|
||||
async def test_find_candidates_finds_exact_title_match(client, real_resolver):
|
||||
"""Exact title match is a setup-time suggestion only — proposals carry scores."""
|
||||
client.items[14] = make_item(id=14, title="sodium_azide", body="")
|
||||
proposals = await real_resolver.find_candidates("sodium_azide")
|
||||
assert any(p.item_id == 14 for p in proposals)
|
||||
proposal = next(p for p in proposals if p.item_id == 14)
|
||||
assert proposal.score is not None
|
||||
|
||||
|
||||
async def test_exact_title_never_applies_silently_at_runtime(client, real_resolver):
|
||||
"""An exact title match alone must not create a runtime binding (plan §5.4)."""
|
||||
client.items[14] = make_item(id=14, title="sodium_azide", body="")
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
await real_resolver.resolve("sodium_azide")
|
||||
@@ -0,0 +1,184 @@
|
||||
"""rest.py — read-only REST mirror for the mobile app (plan §8, §13).
|
||||
|
||||
Bearer-token protected, mutations never exposed, same compact shapes and typed
|
||||
errors as the MCP tools. Tests use Starlette's TestClient (ASGI, no network).
|
||||
|
||||
Convention under test: the app resolves its read services via
|
||||
``mcp_server.server.build_services`` — monkeypatched here (see conftest).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from mcp_server.errors import LabVoiceError
|
||||
|
||||
TOKEN = "app-bearer-token-123"
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rest_settings(settings):
|
||||
settings.labvoice_rest_token = TOKEN
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rest_app(rest_settings, patch_build_services) -> FastAPI:
|
||||
patch_build_services()
|
||||
from mcp_server.rest import create_rest_app
|
||||
|
||||
return create_rest_app(rest_settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api(rest_app) -> TestClient:
|
||||
return TestClient(rest_app)
|
||||
|
||||
|
||||
# --- auth --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_token_is_rejected(api):
|
||||
assert api.get("/api/experiments").status_code == 401
|
||||
assert api.get("/api/experiments/123/state").status_code == 401
|
||||
assert api.get("/api/experiments/123/next-step").status_code == 401
|
||||
assert api.get("/api/templates").status_code == 401
|
||||
|
||||
|
||||
def test_wrong_token_is_rejected(api):
|
||||
response = api.get("/api/experiments", headers={"Authorization": "Bearer nope"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_correct_token_passes(api):
|
||||
response = api.get("/api/experiments", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_rest_app_requires_token_configured(settings, patch_build_services):
|
||||
patch_build_services()
|
||||
from mcp_server.rest import create_rest_app
|
||||
|
||||
settings.labvoice_rest_token = None
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
create_rest_app(settings)
|
||||
assert not isinstance(excinfo.value, NotImplementedError)
|
||||
|
||||
|
||||
# --- endpoints ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_find_experiments_returns_compact_summaries(api):
|
||||
response = api.get("/api/experiments", params={"q": "plasmid"}, headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert isinstance(payload, list) and payload
|
||||
summary = payload[0]
|
||||
assert summary["id"] == 123
|
||||
assert summary["title"] == "Plasmid prep"
|
||||
|
||||
|
||||
def test_next_step_is_the_hot_path(api):
|
||||
"""Plan §8: GET /api/experiments/{id}/next-step returns the parsed step + stock."""
|
||||
response = api.get("/api/experiments/123/next-step", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["experiment_id"] == 123
|
||||
assert payload["step"]["id"] == 9
|
||||
assert payload["step"]["finished"] is False
|
||||
assert payload["consumables"][0]["resource_key"] == "ethanol_absolute"
|
||||
assert payload["consumables"][0]["quantity"] == 2.0
|
||||
assert payload["stock"][0]["container_id"] == 31
|
||||
assert payload["stock"][0]["available"] == 50.0
|
||||
|
||||
|
||||
def test_next_step_null_when_protocol_complete(api, client):
|
||||
for step in client.experiments[123].steps:
|
||||
step.finished = True
|
||||
response = api.get("/api/experiments/123/next-step", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.json() is None
|
||||
|
||||
|
||||
def test_state_snapshot_shape(api):
|
||||
"""Plan §8: `state` is what the app renders: title, steps, stock, next step."""
|
||||
response = api.get("/api/experiments/123/state", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
state = response.json()
|
||||
assert state["experiment_id"] == 123
|
||||
assert state["title"] == "Plasmid prep"
|
||||
assert state["unfinished_steps"] == [9, 10]
|
||||
next_step = state["next_step"]
|
||||
assert set(next_step) == {"experiment_id", "step", "consumables", "stock"}
|
||||
assert next_step["step"]["id"] == 9
|
||||
assert len(next_step["consumables"]) == 1
|
||||
assert {c["container_id"] for c in next_step["stock"]} == {31, 32}
|
||||
|
||||
|
||||
def test_state_of_unknown_experiment_is_typed_error(api):
|
||||
response = api.get("/api/experiments/9999/state", headers=AUTH)
|
||||
assert response.status_code == 404
|
||||
payload = response.json()
|
||||
assert payload["error"] == "not_found"
|
||||
assert "message" in payload
|
||||
|
||||
|
||||
def test_templates_endpoint_lists_summaries(api, client):
|
||||
from fakes import make_template
|
||||
|
||||
client.templates[7] = make_template()
|
||||
response = api.get("/api/templates", headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
templates = response.json()
|
||||
summary = templates[0]
|
||||
assert summary["id"] == 7
|
||||
assert summary["title"] == "PCR cleanup"
|
||||
assert "short_description" in summary
|
||||
assert summary["tags"] == ["dna", "cleanup"]
|
||||
|
||||
|
||||
def test_templates_search_ranks(api, client):
|
||||
from fakes import make_template
|
||||
|
||||
client.templates[7] = make_template()
|
||||
response = api.get("/api/templates", params={"q": "pcr"}, headers=AUTH)
|
||||
assert response.status_code == 200
|
||||
assert response.json()[0]["id"] == 7
|
||||
|
||||
|
||||
# --- mutations are never exposed ----------------------------------------------
|
||||
|
||||
|
||||
def test_no_mutation_routes_exist(rest_app):
|
||||
"""Plan §8: mutations exist only as MCP tools so every mutation is journaled."""
|
||||
paths = rest_app.openapi().get("paths", {})
|
||||
assert paths, "REST app must document its endpoints"
|
||||
for path, operations in paths.items():
|
||||
for method in operations:
|
||||
assert method.lower() == "get", f"{method.upper()} {path} must not exist"
|
||||
|
||||
|
||||
# --- unit-level helpers --------------------------------------------------------
|
||||
|
||||
|
||||
def test_error_payload_shape():
|
||||
from mcp_server.rest import error_payload
|
||||
|
||||
assert error_payload("clarification", "Which ethanol?") == {
|
||||
"error": "clarification",
|
||||
"message": "Which ethanol?",
|
||||
}
|
||||
|
||||
|
||||
def test_verify_token_accepts_configured_token(rest_settings):
|
||||
from mcp_server.rest import verify_token
|
||||
|
||||
assert verify_token(TOKEN) is None
|
||||
|
||||
|
||||
def test_verify_token_rejects_wrong_token(rest_settings):
|
||||
from mcp_server.rest import verify_token
|
||||
|
||||
with pytest.raises(LabVoiceError):
|
||||
verify_token("wrong")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Safety checks as tests (plan §13).
|
||||
|
||||
* a read-only API key must fail cleanly on every mutating tool
|
||||
* failed workflows leave no partial state (no comment, no finished step)
|
||||
* the API key never appears in any tool result
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from mcp.client import Client
|
||||
|
||||
from fakes import make_template
|
||||
from mcp_server.tools import register_tools
|
||||
|
||||
TOKEN_SAFE_MESSAGE = "labvoice-secret-key-do-not-leak"
|
||||
|
||||
MUTATING_TOOLS = {
|
||||
"complete_next_protocol_step": {"experiment_id": 123},
|
||||
"complete_protocol_step": {"experiment_id": 123, "step_id": 9},
|
||||
"record_protocol_observation": {"experiment_id": 123, "comment": "note"},
|
||||
"create_experiment_from_template": {"template_id": 7},
|
||||
"adjust_inventory": {"amount": 5.0, "unit": "mL", "container_id": 31},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mcp_client(patch_build_services, client):
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
patch_build_services()
|
||||
client.templates[7] = make_template()
|
||||
mcp = FastMCP("labvoice-safety")
|
||||
register_tools(mcp)
|
||||
async with Client(mcp, raise_exceptions=False) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def _state_fingerprint(client):
|
||||
experiment = client.experiments[123]
|
||||
return {
|
||||
"steps": [(s.id, s.finished) for s in experiment.steps],
|
||||
"comments": [c.body for c in experiment.comments],
|
||||
"containers": [
|
||||
(i.id, c.sub_id, c.qty_stored)
|
||||
for i in client.items.values()
|
||||
for c in i.containers
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", sorted(MUTATING_TOOLS))
|
||||
async def test_read_only_key_fails_cleanly_on_every_mutating_tool(
|
||||
mcp_client, client, tool
|
||||
):
|
||||
"""Plan §13: read-only key ⇒ every mutation fails with a clear message."""
|
||||
client.read_only = True
|
||||
before = _state_fingerprint(client)
|
||||
|
||||
result = await mcp_client.call_tool(tool, MUTATING_TOOLS[tool])
|
||||
payload = None
|
||||
structured = getattr(result, "structured_content", None)
|
||||
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
|
||||
if structured is not None:
|
||||
payload = structured
|
||||
assert getattr(result, "is_error", False) or (
|
||||
payload is not None and payload.get("ok") is False
|
||||
) or "permission" in texts.lower() or "read-only" in texts.lower(), (
|
||||
f"{tool} must not silently succeed with a read-only key"
|
||||
)
|
||||
|
||||
assert _state_fingerprint(client) == before, f"{tool} must not mutate anything"
|
||||
|
||||
|
||||
async def test_aborted_workflow_leaves_no_partial_state(mcp_client, client, resolver_stub):
|
||||
"""Unresolvable resource ⇒ no finished step, no comment, no stock change."""
|
||||
resolver_stub.mappings.clear()
|
||||
before = _state_fingerprint(client)
|
||||
|
||||
result = await mcp_client.call_tool(
|
||||
"complete_next_protocol_step", {"experiment_id": 123}
|
||||
)
|
||||
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
|
||||
assert result.is_error or "clarification" in texts.lower() or "not know" in texts.lower()
|
||||
|
||||
assert _state_fingerprint(client) == before
|
||||
|
||||
|
||||
async def test_error_results_never_contain_the_api_key(mcp_client, client, resolver_stub):
|
||||
client.read_only = True
|
||||
resolver_stub.mappings.clear()
|
||||
for tool, args in MUTATING_TOOLS.items():
|
||||
result = await mcp_client.call_tool(tool, dict(args))
|
||||
texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", []))
|
||||
structured = getattr(result, "structured_content", None)
|
||||
blob = texts + json.dumps(structured, default=str)
|
||||
assert TOKEN_SAFE_MESSAGE not in blob
|
||||
@@ -0,0 +1,132 @@
|
||||
"""templates.py — template search/ranking and creation flow (plan §9, §13)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from fakes import FakeElabftwClient, FakeTemplate, make_template
|
||||
from mcp_server.errors import NotFoundError
|
||||
|
||||
def ranking_client() -> FakeElabftwClient:
|
||||
return FakeElabftwClient(
|
||||
templates=[
|
||||
FakeTemplate(id=1, title="Cell culture", description="Maintain adherent lines."),
|
||||
FakeTemplate(
|
||||
id=2,
|
||||
title="Protein extraction",
|
||||
description="Lyse cells; collect supernatant in pcr tubes.",
|
||||
),
|
||||
FakeTemplate(id=3, title="Ethanol precipitation", description="Precipitate DNA.", tags=["pcr"]),
|
||||
FakeTemplate(id=4, title="PCR cleanup", description="Purify an amplicon."),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# --- short description ------------------------------------------------------
|
||||
|
||||
|
||||
async def test_short_description_is_first_paragraph(templates_service, client):
|
||||
client.templates[7] = make_template()
|
||||
result = await templates_service.list_templates()
|
||||
assert result[0].short_description == "Purify an amplicon after PCR."
|
||||
|
||||
|
||||
async def test_short_description_truncated_to_limit(templates_service, settings, client):
|
||||
settings.labvoice_template_desc_limit = 20
|
||||
client.templates[7] = make_template(
|
||||
description="P" * 100 + "\n\nSecond paragraph that must not appear."
|
||||
)
|
||||
result = await templates_service.list_templates()
|
||||
summary = result[0]
|
||||
assert len(summary.short_description) <= 20
|
||||
assert "Second paragraph" not in summary.short_description
|
||||
|
||||
|
||||
async def test_short_description_empty_when_no_description(templates_service, client):
|
||||
client.templates[7] = make_template(description="")
|
||||
result = await templates_service.list_templates()
|
||||
assert result[0].short_description == ""
|
||||
|
||||
|
||||
async def test_summary_carries_id_title_tags(templates_service, client):
|
||||
client.templates[7] = make_template()
|
||||
result = await templates_service.list_templates()
|
||||
summary = result[0]
|
||||
assert summary.id == 7
|
||||
assert summary.title == "PCR cleanup"
|
||||
assert summary.tags == ["dna", "cleanup"]
|
||||
|
||||
|
||||
# --- ranking ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_no_query_preserves_listing_order(templates_service, client):
|
||||
client.templates.update({t.id: t for t in ranking_client().templates.values()})
|
||||
result = await templates_service.list_templates()
|
||||
assert [t.id for t in result] == [1, 2, 3, 4]
|
||||
|
||||
|
||||
async def test_title_match_ranks_above_tag_and_description_match(templates_service, client):
|
||||
client.templates.update({t.id: t for t in ranking_client().templates.values()})
|
||||
result = await templates_service.list_templates(query="pcr")
|
||||
ids = [t.id for t in result]
|
||||
assert ids.index(4) < ids.index(3) < ids.index(2), (
|
||||
"expected title(4) > tags(3) > description(2) per plan §9 weights"
|
||||
)
|
||||
|
||||
|
||||
async def test_non_matching_templates_rank_below_matches(templates_service, client):
|
||||
client.templates.update({t.id: t for t in ranking_client().templates.values()})
|
||||
result = await templates_service.list_templates(query="pcr")
|
||||
assert result[-1].id == 1
|
||||
|
||||
|
||||
async def test_limit_is_respected(templates_service, client):
|
||||
client.templates.update({t.id: t for t in ranking_client().templates.values()})
|
||||
result = await templates_service.list_templates(query="pcr", limit=2)
|
||||
assert len(result) == 2
|
||||
assert result[0].id == 4
|
||||
|
||||
|
||||
# --- creation ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_from_template_returns_experiment_and_first_step(templates_service, client):
|
||||
client.templates[7] = make_template()
|
||||
result = await templates_service.create_experiment_from_template(7)
|
||||
assert result.ok is True
|
||||
assert result.experiment_id == 1000
|
||||
assert result.title == "PCR cleanup"
|
||||
assert result.first_step is not None
|
||||
assert result.first_step.id == 41
|
||||
assert result.first_step.finished is False
|
||||
|
||||
|
||||
async def test_create_with_explicit_title(templates_service, client):
|
||||
client.templates[7] = make_template()
|
||||
result = await templates_service.create_experiment_from_template(7, title="My amplicon run")
|
||||
assert result.title == "My amplicon run"
|
||||
|
||||
|
||||
async def test_created_experiment_copies_template_steps_and_annotations(
|
||||
templates_service, client
|
||||
):
|
||||
"""eLabFTW copies template steps — annotations must come along (plan §9)."""
|
||||
from fakes import ANNOTATION_ETHANOL_FIFO, FakeStep
|
||||
|
||||
client.templates[7] = make_template(
|
||||
steps=[FakeStep(id=41, body=f"Add ethanol\n{ANNOTATION_ETHANOL_FIFO}", ordering=1)]
|
||||
)
|
||||
result = await templates_service.create_experiment_from_template(7)
|
||||
created = client.experiments[result.experiment_id]
|
||||
assert "labvoice:v1" in created.steps[0].body
|
||||
|
||||
|
||||
async def test_create_from_unknown_template_fails_cleanly(templates_service, client):
|
||||
with pytest.raises((NotFoundError, KeyError)):
|
||||
await templates_service.create_experiment_from_template(9999)
|
||||
|
||||
|
||||
async def test_search_index_build_is_optional(templates_service, client):
|
||||
"""Lexical fallback works without an explicitly built index (plan §9 fallback)."""
|
||||
client.templates.update({t.id: t for t in ranking_client().templates.values()})
|
||||
result = await templates_service.list_templates(query="pcr")
|
||||
assert result[0].id == 4
|
||||
@@ -0,0 +1,169 @@
|
||||
"""tools.py + MCP surface — small-model-friendly tool schemas (plan §6, §13).
|
||||
|
||||
Checks the tool *surface* (names, short descriptions, small input schemas)
|
||||
over the real FastMCP server using the in-process client, plus a golden
|
||||
result for the headline workflow.
|
||||
|
||||
Note: the plan's "ten tools" are the reads + aggregates; the scaffold adds
|
||||
four admin/setup tools (scan/map/export/import), so the registered surface
|
||||
is fourteen functions.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from mcp.client import Client
|
||||
|
||||
from mcp_server.tools import TOOL_FUNCTIONS, register_tools
|
||||
|
||||
EXPECTED_TOOLS = {
|
||||
"find_experiments",
|
||||
"get_experiment_context",
|
||||
"get_next_protocol_step",
|
||||
"list_experiment_templates",
|
||||
"validate_protocol_template",
|
||||
"complete_next_protocol_step",
|
||||
"complete_protocol_step",
|
||||
"record_protocol_observation",
|
||||
"create_experiment_from_template",
|
||||
"adjust_inventory",
|
||||
"scan_instance",
|
||||
"map_resource",
|
||||
"export_mapping",
|
||||
"import_mapping",
|
||||
}
|
||||
|
||||
|
||||
def result_json(call_result):
|
||||
"""Extract the JSON payload from a CallToolResult (structured or text)."""
|
||||
structured = getattr(call_result, "structured_content", None)
|
||||
if structured is not None:
|
||||
return structured
|
||||
for block in call_result.content:
|
||||
text = getattr(block, "text", None)
|
||||
if text:
|
||||
return json.loads(text)
|
||||
raise AssertionError(f"No usable content in tool result: {call_result!r}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mcp_server(patch_build_services):
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
patch_build_services()
|
||||
mcp = FastMCP("labvoice-test")
|
||||
register_tools(mcp)
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mcp_client(mcp_server):
|
||||
async with Client(mcp_server, raise_exceptions=True) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# --- tool surface ------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_all_tools_registered_and_listed(mcp_client):
|
||||
tools = await mcp_client.list_tools()
|
||||
names = {t.name for t in tools}
|
||||
assert names == EXPECTED_TOOLS
|
||||
assert len(TOOL_FUNCTIONS) == len(EXPECTED_TOOLS)
|
||||
|
||||
|
||||
async def test_every_tool_has_a_short_description(mcp_client):
|
||||
"""Plan §6: tool descriptions are one or two short sentences."""
|
||||
for tool in await mcp_client.list_tools():
|
||||
assert tool.description, f"{tool.name} needs a description"
|
||||
assert len(tool.description) <= 300, f"{tool.name} description too long for a small model"
|
||||
|
||||
|
||||
async def test_input_schemas_are_small(mcp_client):
|
||||
"""Plan §13 lint: small input schemas — no kitchen-sink parameter lists."""
|
||||
for tool in await mcp_client.list_tools():
|
||||
properties = tool.inputSchema.get("properties", {})
|
||||
assert len(properties) <= 5, f"{tool.name} takes {len(properties)} parameters"
|
||||
assert tool.inputSchema.get("type") == "object"
|
||||
|
||||
|
||||
async def test_input_schemas_match_function_signatures(mcp_client):
|
||||
import inspect
|
||||
|
||||
for tool in await mcp_client.list_tools():
|
||||
fn = next(f for f in TOOL_FUNCTIONS if f.__name__ == tool.name)
|
||||
params = inspect.signature(fn).parameters
|
||||
properties = tool.inputSchema.get("properties", {})
|
||||
assert set(properties) == set(params), f"{tool.name}: schema/args mismatch"
|
||||
required = set(tool.inputSchema.get("required", []))
|
||||
expected_required = {
|
||||
name for name, p in params.items() if p.default is inspect.Parameter.empty
|
||||
}
|
||||
assert required == expected_required, f"{tool.name}: required set mismatch"
|
||||
|
||||
|
||||
async def test_mutating_tools_are_clearly_named(mcp_client):
|
||||
"""A small model must not confuse reads with mutations (plan §2)."""
|
||||
mutating = {
|
||||
"complete_next_protocol_step",
|
||||
"complete_protocol_step",
|
||||
"record_protocol_observation",
|
||||
"create_experiment_from_template",
|
||||
"adjust_inventory",
|
||||
"map_resource",
|
||||
"scan_instance",
|
||||
"import_mapping",
|
||||
}
|
||||
tools = {t.name: t for t in await mcp_client.list_tools()}
|
||||
for name in mutating:
|
||||
assert name in tools
|
||||
|
||||
|
||||
# --- golden workflow result over the MCP transport -----------------------------
|
||||
|
||||
|
||||
async def test_headline_workflow_golden_result_over_mcp(mcp_client, client):
|
||||
"""Plan §13: golden result for complete_next_protocol_step (§7)."""
|
||||
call_result = await mcp_client.call_tool(
|
||||
"complete_next_protocol_step",
|
||||
{"experiment_id": 123, "comment": "Done at the bench"},
|
||||
)
|
||||
payload = result_json(call_result)
|
||||
|
||||
assert payload["ok"] is True
|
||||
assert payload["status"] == "completed"
|
||||
assert payload["experiment_id"] == 123
|
||||
assert payload["step"]["id"] == 9
|
||||
assert payload["step"]["finished"] is True
|
||||
assert payload["consumed"][0]["resource_key"] == "ethanol_absolute"
|
||||
assert payload["consumed"][0]["container_id"] == 31
|
||||
assert payload["consumed"][0]["amount"] == "2.0 mL"
|
||||
assert payload["consumed"][0]["remaining"] == "48.0 mL"
|
||||
assert payload["next_step"]["id"] == 10
|
||||
# stock really moved in eLabFTW
|
||||
assert client.items[12].containers[0].qty_stored == 48.0
|
||||
|
||||
|
||||
async def test_read_tool_over_mcp_returns_compact_json(mcp_client):
|
||||
call_result = await mcp_client.call_tool("get_next_protocol_step", {"experiment_id": 123})
|
||||
payload = result_json(call_result)
|
||||
assert payload["step"]["id"] == 9
|
||||
assert payload["consumables"][0]["resource_key"] == "ethanol_absolute"
|
||||
|
||||
|
||||
async def test_clarification_over_mcp_is_a_typed_error(mcp_client, resolver_stub):
|
||||
resolver_stub.mappings.clear()
|
||||
call_result = await mcp_client.call_tool(
|
||||
"complete_next_protocol_step", {"experiment_id": 123}
|
||||
)
|
||||
payload = result_json(call_result)
|
||||
# Either the transport surfaces the error or the payload carries it —
|
||||
# both are acceptable, but it must be the clarification kind either way.
|
||||
if getattr(call_result, "is_error", False):
|
||||
text = " ".join(getattr(b, "text", "") for b in call_result.content)
|
||||
assert "clarification" in text or "ethanol" in text
|
||||
else:
|
||||
assert payload.get("error") == "clarification" or payload.get("status") in (
|
||||
"clarification",
|
||||
"reverted",
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
"""workflows/inventory.py — stock reads + adjust_inventory (plan §6, §10)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server.errors import ClarificationNeededError, NotFoundError
|
||||
|
||||
|
||||
# --- stock reads ------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_stock_for_resource_lists_containers(inventory):
|
||||
stock = await inventory.stock_for_resource("ethanol_absolute")
|
||||
assert [s.container_id for s in stock] == [31, 32]
|
||||
assert stock[0].available == 50.0
|
||||
assert stock[0].unit == "mL"
|
||||
assert stock[0].amount == "50.0 mL"
|
||||
|
||||
|
||||
async def test_stock_for_unknown_resource_raises_clarification(inventory, resolver_stub):
|
||||
resolver_stub.mappings.clear()
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
await inventory.stock_for_resource("unobtainium")
|
||||
|
||||
|
||||
# --- adjust_inventory -------------------------------------------------------
|
||||
|
||||
|
||||
async def test_adjust_adds_to_container_by_id(inventory, client):
|
||||
result = await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
|
||||
assert result.ok is True
|
||||
assert result.container_id == 31
|
||||
assert result.amount == "500.0 mL"
|
||||
assert result.remaining == "550.0 mL"
|
||||
assert client.items[12].containers[0].qty_stored == 550.0
|
||||
|
||||
|
||||
async def test_adjust_supports_correction_negative_delta(inventory, client):
|
||||
await inventory.adjust_inventory(amount=-2.0, unit="mL", container_id=31)
|
||||
assert client.items[12].containers[0].qty_stored == 48.0
|
||||
|
||||
|
||||
async def test_adjust_by_resource_key_uses_allocable_container(inventory, client):
|
||||
result = await inventory.adjust_inventory(
|
||||
amount=100.0, unit="mL", resource_key="ethanol_absolute"
|
||||
)
|
||||
assert result.resource_key == "ethanol_absolute"
|
||||
assert result.container_id == 31
|
||||
assert result.remaining == "150.0 mL"
|
||||
|
||||
|
||||
async def test_adjust_converts_units(inventory, client):
|
||||
"""“Add half a litre” against an mL-stocked container."""
|
||||
await inventory.adjust_inventory(amount=0.5, unit="L", container_id=31)
|
||||
assert client.items[12].containers[0].qty_stored == 550.0
|
||||
|
||||
|
||||
async def test_adjust_rejects_non_whitelisted_unit(inventory):
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
await inventory.adjust_inventory(amount=1.0, unit="fl-oz", container_id=31)
|
||||
|
||||
|
||||
async def test_adjust_rejects_cross_dimension_unit(inventory):
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
await inventory.adjust_inventory(amount=5.0, unit="g", container_id=31)
|
||||
|
||||
|
||||
async def test_adjust_unknown_container_fails_cleanly(inventory):
|
||||
with pytest.raises(NotFoundError):
|
||||
await inventory.adjust_inventory(amount=1.0, unit="mL", container_id=9999)
|
||||
|
||||
|
||||
async def test_adjust_requires_container_or_resource(inventory):
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
await inventory.adjust_inventory(amount=1.0, unit="mL")
|
||||
|
||||
|
||||
async def test_adjust_is_journaled(inventory, journal):
|
||||
await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
|
||||
assert journal.db_path.exists()
|
||||
|
||||
|
||||
async def test_adjust_failure_does_not_silently_lose_data(inventory, client, monkeypatch):
|
||||
"""If the write fails, the workflow surfaces the error (journaled saga)."""
|
||||
from mcp_server.errors import ApiError
|
||||
|
||||
async def fail_write(entity_type, entity_id, subitem_id, qty_stored):
|
||||
raise ApiError("write refused")
|
||||
|
||||
monkeypatch.setattr(inventory.client, "set_container_quantity", fail_write)
|
||||
with pytest.raises(ApiError):
|
||||
await inventory.adjust_inventory(amount=500.0, unit="mL", container_id=31)
|
||||
assert client.items[12].containers[0].qty_stored == 50.0
|
||||
@@ -0,0 +1,338 @@
|
||||
"""workflows/protocol.py — the step-completion saga (plan §7, §10).
|
||||
|
||||
Golden path: select next step → parse annotation → resolve keys → allocate →
|
||||
validate stock → decrement containers → finish step → post comment → verify.
|
||||
Failure semantics: clarifications abort before any mutation; journal failures
|
||||
compensate (reverted) or escalate (partial_failure + audit comment).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from fakes import ANNOTATION_ETHANOL_FIFO, FakeContainer, FakeStep
|
||||
from mcp_server.errors import (
|
||||
ApiError,
|
||||
ClarificationNeededError,
|
||||
InsufficientStockError,
|
||||
NotFoundError,
|
||||
PermissionDeniedError,
|
||||
)
|
||||
|
||||
|
||||
# --- get_next_step ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_next_step_is_lowest_ordering_unfinished(protocol):
|
||||
next_step = await protocol.get_next_step(123)
|
||||
assert next_step is not None
|
||||
assert next_step.experiment_id == 123
|
||||
assert next_step.step.id == 9
|
||||
assert next_step.step.body.startswith("Add ethanol")
|
||||
|
||||
|
||||
async def test_next_step_includes_parsed_consumables(protocol):
|
||||
next_step = await protocol.get_next_step(123)
|
||||
assert len(next_step.consumables) == 1
|
||||
assert next_step.consumables[0].resource_key == "ethanol_absolute"
|
||||
assert next_step.consumables[0].quantity == 2.0
|
||||
|
||||
|
||||
async def test_next_step_includes_stock_preview(protocol):
|
||||
next_step = await protocol.get_next_step(123)
|
||||
assert [s.container_id for s in next_step.stock] == [31, 32]
|
||||
assert next_step.stock[0].available == 50.0
|
||||
assert next_step.stock[0].unit == "mL"
|
||||
|
||||
|
||||
async def test_next_step_none_when_protocol_complete(protocol, client):
|
||||
for step in client.experiments[123].steps:
|
||||
step.finished = True
|
||||
assert await protocol.get_next_step(123) is None
|
||||
|
||||
|
||||
async def test_next_step_skips_finished_steps(protocol, client):
|
||||
client.experiments[123].steps[0].finished = True
|
||||
next_step = await protocol.get_next_step(123)
|
||||
assert next_step.step.id == 10
|
||||
|
||||
|
||||
async def test_next_step_unknown_experiment_fails_cleanly(protocol):
|
||||
with pytest.raises(NotFoundError):
|
||||
await protocol.get_next_step(9999)
|
||||
|
||||
|
||||
# --- complete_next_step: golden path ----------------------------------------
|
||||
|
||||
|
||||
async def test_golden_path_completes_step_and_consumes_stock(protocol, client):
|
||||
result = await protocol.complete_next_step(123, comment="Done at the bench")
|
||||
|
||||
assert result.ok is True
|
||||
assert result.status == "completed"
|
||||
assert result.experiment_id == 123
|
||||
assert result.step.id == 9
|
||||
assert result.step.finished is True
|
||||
assert result.next_step is not None
|
||||
assert result.next_step.id == 10
|
||||
|
||||
# stock decremented (fifo: container 31 first)
|
||||
assert client.items[12].containers[0].qty_stored == 48.0
|
||||
assert client.items[12].containers[1].qty_stored == 10.0
|
||||
|
||||
# compact consumed record with TTS-ready amounts (plan §6)
|
||||
assert len(result.consumed) == 1
|
||||
consumed = result.consumed[0]
|
||||
assert consumed.resource_key == "ethanol_absolute"
|
||||
assert consumed.container_id == 31
|
||||
assert consumed.amount == "2.0 mL"
|
||||
assert consumed.remaining == "48.0 mL"
|
||||
|
||||
# comment posted
|
||||
assert result.comment_id is not None
|
||||
comments = client.experiments[123].comments
|
||||
assert any("Done at the bench" in c.body for c in comments)
|
||||
|
||||
|
||||
async def test_step_is_marked_finished_in_elabftw(protocol, client):
|
||||
await protocol.complete_next_step(123)
|
||||
assert client.experiments[123].steps[0].finished is True
|
||||
|
||||
|
||||
async def test_saga_journals_a_completed_operation(protocol, journal):
|
||||
await protocol.complete_next_step(123)
|
||||
# The journal must have recorded a completed operation for this run; the
|
||||
# audit trail lives in the SQLite file (plan §10).
|
||||
assert journal.db_path.exists()
|
||||
assert journal.db_path.stat().st_size > 0
|
||||
|
||||
|
||||
# --- step selection variants ------------------------------------------------
|
||||
|
||||
|
||||
async def test_complete_with_explicit_step_id(protocol, client):
|
||||
result = await protocol.complete_step(123, 10, comment="redo")
|
||||
assert result.step.id == 10
|
||||
assert result.step.finished is True
|
||||
assert client.experiments[123].steps[1].finished is True
|
||||
|
||||
|
||||
async def test_complete_next_skips_finished_steps(protocol, client):
|
||||
client.experiments[123].steps[0].finished = True
|
||||
result = await protocol.complete_next_step(123)
|
||||
assert result.step.id == 10
|
||||
|
||||
|
||||
# --- no-annotation steps ----------------------------------------------------
|
||||
|
||||
|
||||
async def test_step_without_annotation_completes_and_posts_comment(protocol, client):
|
||||
"""Plan §7.2: no annotation ⇒ complete step + comment, skip inventory."""
|
||||
client.experiments[123].steps[0].body = "Plain step, no annotation"
|
||||
result = await protocol.complete_next_step(123, comment="ok")
|
||||
assert result.status == "completed"
|
||||
assert result.consumed == []
|
||||
assert result.comment_id is not None
|
||||
assert client.calls_of("set_container_quantity") == []
|
||||
|
||||
|
||||
# --- clarification before mutation ------------------------------------------
|
||||
|
||||
|
||||
async def test_unresolvable_resource_key_aborts_without_mutation(protocol, client, resolver_stub):
|
||||
resolver_stub.mappings.clear()
|
||||
client.experiments[123].steps[0] = FakeStep(
|
||||
id=9, body=f"Add mystery reagent\n{ANNOTATION_ETHANOL_FIFO}", ordering=1
|
||||
)
|
||||
with pytest.raises(ClarificationNeededError):
|
||||
await protocol.complete_next_step(123)
|
||||
|
||||
assert client.calls_of("set_container_quantity") == []
|
||||
assert client.calls_of("finish_step") == []
|
||||
assert client.calls_of("post_comment") == []
|
||||
assert client.experiments[123].steps[0].finished is False
|
||||
|
||||
|
||||
async def test_insufficient_stock_aborts_without_mutation(protocol, client):
|
||||
client.items[12].containers = [FakeContainer(sub_id=31, qty_stored=1.0, unit="mL")]
|
||||
with pytest.raises(InsufficientStockError) as excinfo:
|
||||
await protocol.complete_next_step(123)
|
||||
assert "1.0" in str(excinfo.value)
|
||||
assert client.calls_of("set_container_quantity") == []
|
||||
assert client.calls_of("finish_step") == []
|
||||
|
||||
|
||||
# --- optional consumables ---------------------------------------------------
|
||||
|
||||
|
||||
async def test_optional_consumable_missing_stock_is_skipped_with_warning(protocol, client):
|
||||
"""Plan §7.5: optional consumables are skipped with a warning, step completes."""
|
||||
annotated = (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"consumables": ['
|
||||
'{"resource_key": "ethanol_absolute", "quantity": 2.0, "unit": "mL"},'
|
||||
'{"resource_key": "peanut_oil", "quantity": 1.0, "unit": "mL", "optional": true}'
|
||||
"]}\n"
|
||||
"-->"
|
||||
)
|
||||
client.experiments[123].steps[0] = FakeStep(id=9, body=f"Mix\n{annotated}", ordering=1)
|
||||
|
||||
result = await protocol.complete_next_step(123)
|
||||
assert result.status == "completed"
|
||||
assert [c.resource_key for c in result.consumed] == ["ethanol_absolute"]
|
||||
|
||||
|
||||
# --- prompt_quantity --------------------------------------------------------
|
||||
|
||||
|
||||
def _prompt_annotation_body() -> str:
|
||||
return (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"prompt_quantity": true, "consumables": ['
|
||||
'{"resource_key": "ethanol_absolute", "quantity": null, "unit": "mL"}'
|
||||
"]}\n"
|
||||
"-->"
|
||||
)
|
||||
|
||||
|
||||
async def test_prompt_quantity_without_quantities_asks_for_clarification(protocol, client):
|
||||
client.experiments[123].steps[0] = FakeStep(
|
||||
id=9, body=f"How much did you use?\n{_prompt_annotation_body()}", ordering=1
|
||||
)
|
||||
with pytest.raises(ClarificationNeededError) as excinfo:
|
||||
await protocol.complete_next_step(123)
|
||||
assert excinfo.value.options, "clarification should name the resources needing amounts"
|
||||
assert client.calls_of("set_container_quantity") == []
|
||||
|
||||
|
||||
async def test_prompt_quantity_with_supplied_quantities_completes(protocol, client):
|
||||
client.experiments[123].steps[0] = FakeStep(
|
||||
id=9, body=f"How much did you use?\n{_prompt_annotation_body()}", ordering=1
|
||||
)
|
||||
result = await protocol.complete_next_step(123, quantities={"ethanol_absolute": 5.0})
|
||||
assert result.status == "completed"
|
||||
assert result.consumed[0].amount == "5.0 mL"
|
||||
assert client.items[12].containers[0].qty_stored == 45.0
|
||||
|
||||
|
||||
# --- allocation behaviour in the saga ---------------------------------------
|
||||
|
||||
|
||||
async def test_fifo_split_across_containers(protocol, client):
|
||||
client.items[12].containers[0].qty_stored = 1.0
|
||||
result = await protocol.complete_next_step(123)
|
||||
assert [(c.container_id, c.amount) for c in result.consumed] == [
|
||||
(31, "1.0 mL"),
|
||||
(32, "1.0 mL"),
|
||||
]
|
||||
assert client.items[12].containers[0].qty_stored == 0.0
|
||||
assert client.items[12].containers[1].qty_stored == 9.0
|
||||
|
||||
|
||||
async def test_specific_allocation_uses_the_hinted_container(protocol, client):
|
||||
annotated = (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
|
||||
' "unit": "mL", "allocation": "specific", "container_id": 32}]}\n'
|
||||
"-->"
|
||||
)
|
||||
client.experiments[123].steps[0] = FakeStep(id=9, body=f"x\n{annotated}", ordering=1)
|
||||
result = await protocol.complete_next_step(123)
|
||||
assert result.consumed[0].container_id == 32
|
||||
assert client.items[12].containers[1].qty_stored == 8.0
|
||||
|
||||
|
||||
async def test_unit_conversion_between_compatible_units(protocol, client):
|
||||
"""Required 0.02 L (= 20 mL) against mL-stocked containers."""
|
||||
annotated = (
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 0.02,'
|
||||
' "unit": "L"}]}\n'
|
||||
"-->"
|
||||
)
|
||||
client.experiments[123].steps[0] = FakeStep(id=9, body=f"x\n{annotated}", ordering=1)
|
||||
result = await protocol.complete_next_step(123)
|
||||
assert client.items[12].containers[0].qty_stored == 30.0
|
||||
assert result.consumed[0].amount == "20.0 mL"
|
||||
|
||||
|
||||
# --- failure & compensation (plan §10) ---------------------------------------
|
||||
|
||||
|
||||
async def test_failure_during_finish_step_returns_reverted_and_restores_stock(
|
||||
protocol, client, monkeypatch
|
||||
):
|
||||
"""Plan §10: compensate by restoring decremented quantities, then `reverted`."""
|
||||
|
||||
async def fail_finish(entity_type, entity_id, step_id):
|
||||
raise ApiError("eLabFTW is unhappy.")
|
||||
|
||||
monkeypatch.setattr(protocol.client, "finish_step", fail_finish)
|
||||
|
||||
result = await protocol.complete_next_step(123)
|
||||
|
||||
assert result.status == "reverted"
|
||||
assert client.items[12].containers[0].qty_stored == 50.0
|
||||
assert client.experiments[123].steps[0].finished is False
|
||||
assert client.calls_of("post_comment") == []
|
||||
|
||||
|
||||
async def test_comment_failure_after_finish_compensates_stock(protocol, client, monkeypatch):
|
||||
async def fail_comment(entity_type, entity_id, body):
|
||||
raise ApiError("comment refused.")
|
||||
|
||||
monkeypatch.setattr(protocol.client, "post_comment", fail_comment)
|
||||
|
||||
result = await protocol.complete_next_step(123)
|
||||
assert result.status == "reverted"
|
||||
assert client.items[12].containers[0].qty_stored == 50.0
|
||||
|
||||
|
||||
async def test_failed_compensation_marks_partial_failure_and_posts_audit_comment(
|
||||
protocol, client, monkeypatch
|
||||
):
|
||||
async def fail_finish(entity_type, entity_id, step_id):
|
||||
raise ApiError("eLabFTW is unhappy.")
|
||||
|
||||
async def fail_restore(entity_type, entity_id, subitem_id, qty_stored):
|
||||
raise ApiError("restore also failed.")
|
||||
|
||||
monkeypatch.setattr(protocol.client, "finish_step", fail_finish)
|
||||
monkeypatch.setattr(protocol.client, "set_container_quantity", fail_restore)
|
||||
|
||||
result = await protocol.complete_next_step(123)
|
||||
|
||||
assert result is not None, "workflow must report partial_failure, not raise"
|
||||
assert result.status == "partial_failure"
|
||||
comments = client.experiments[123].comments
|
||||
assert any("31" in c.body or "ethanol" in c.body.lower() for c in comments), (
|
||||
"partial_failure must post an audit comment naming the inconsistent container"
|
||||
)
|
||||
|
||||
|
||||
# --- protocol_complete edge case (plan §7.1) ---------------------------------
|
||||
|
||||
|
||||
async def test_complete_next_with_no_steps_left_returns_protocol_complete(protocol, client):
|
||||
for step in client.experiments[123].steps:
|
||||
step.finished = True
|
||||
result = await protocol.complete_next_step(123)
|
||||
assert result.status == "protocol_complete"
|
||||
assert result.consumed == []
|
||||
assert client.calls_of("set_container_quantity") == []
|
||||
assert client.calls_of("post_comment") == []
|
||||
|
||||
|
||||
# --- guard rails -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_completing_an_already_finished_explicit_step_errors(protocol, client):
|
||||
"""Plan §13 safety: immutable (already finished) step ⇒ explicit error."""
|
||||
client.experiments[123].steps[0].finished = True
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await protocol.complete_step(123, 9)
|
||||
assert not isinstance(excinfo.value, NotImplementedError), "scaffold not implemented yet"
|
||||
|
||||
|
||||
async def test_read_only_key_surfaces_permission_error(protocol, client):
|
||||
client.read_only = True
|
||||
with pytest.raises(PermissionDeniedError):
|
||||
await protocol.complete_next_step(123)
|
||||
@@ -0,0 +1,135 @@
|
||||
"""workflows/setup.py — scan_instance, map_resource, validate_protocol_template (plan §5)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from fakes import FakeItem, make_item
|
||||
|
||||
|
||||
# --- scan_instance ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_scan_counts_containers_and_markers(setup, client):
|
||||
client.items[13] = make_item(id=13, title="Water bottle", body="plain", containers=[])
|
||||
result = await setup.scan_instance()
|
||||
assert result.containers_seen == 2 # containers on item 12
|
||||
assert result.markers_found == 1 # marker on item 12
|
||||
|
||||
|
||||
async def test_scan_proposes_marker_matches(setup, client):
|
||||
result = await setup.scan_instance()
|
||||
proposal = next(p for p in result.proposals if p.resource_key == "ethanol_absolute")
|
||||
assert proposal.item_id == 12
|
||||
assert proposal.reason is not None
|
||||
|
||||
|
||||
async def test_scan_proposes_exact_title_match_as_suggestion(setup, client):
|
||||
"""Exact title matches are setup-time suggestions with scores (plan §5.4)."""
|
||||
client.items[14] = FakeItem(id=14, title="sodium_azide", body="")
|
||||
result = await setup.scan_instance()
|
||||
proposal = next((p for p in result.proposals if p.item_id == 14), None)
|
||||
assert proposal is not None
|
||||
assert proposal.resource_key == "sodium_azide"
|
||||
|
||||
|
||||
# --- map_resource -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_map_resource_binds_key(setup, client, resolver_stub):
|
||||
entry = await setup.map_resource("ethanol_absolute", 12)
|
||||
assert entry.resource_key == "ethanol_absolute"
|
||||
assert entry.item_id == 12
|
||||
assert resolver_stub.mappings["ethanol_absolute"] == 12
|
||||
|
||||
|
||||
async def test_map_resource_is_idempotent_upsert(setup, resolver_stub):
|
||||
await setup.map_resource("ethanol_absolute", 12)
|
||||
await setup.map_resource("ethanol_absolute", 12)
|
||||
assert resolver_stub.mappings["ethanol_absolute"] == 12
|
||||
entries = await resolver_stub.list_mappings()
|
||||
assert len([e for e in entries if e.resource_key == "ethanol_absolute"]) == 1
|
||||
|
||||
|
||||
async def test_map_resource_rebinding_updates(setup, resolver_stub):
|
||||
await setup.map_resource("ethanol_absolute", 12)
|
||||
await setup.map_resource("ethanol_absolute", 13)
|
||||
assert resolver_stub.mappings["ethanol_absolute"] == 13
|
||||
|
||||
|
||||
# --- validate_protocol_template ---------------------------------------------
|
||||
|
||||
|
||||
def _annotated_template_client():
|
||||
from fakes import FakeElabftwClient, FakeStep, make_experiment, make_item, make_template
|
||||
|
||||
annotated_step = FakeStep(
|
||||
id=41,
|
||||
body=(
|
||||
"Add ethanol\n"
|
||||
"<!-- labvoice:v1\n"
|
||||
'{"consumables": [{"resource_key": "ethanol_absolute", "quantity": 2.0,'
|
||||
' "unit": "mL"}]}\n'
|
||||
"-->"
|
||||
),
|
||||
ordering=1,
|
||||
)
|
||||
broken_step = FakeStep(
|
||||
id=42, body="Broken\n<!-- labvoice:v1\n{invalid json\n-->", ordering=2
|
||||
)
|
||||
templates = [
|
||||
make_template(
|
||||
id=7,
|
||||
steps=[annotated_step, broken_step],
|
||||
)
|
||||
]
|
||||
return FakeElabftwClient(
|
||||
templates=templates, experiments=[make_experiment()], items=[make_item()]
|
||||
)
|
||||
|
||||
|
||||
async def test_validate_flags_unmapped_resource_key(setup, client, resolver_stub):
|
||||
"""Annotation ok, but the mapping store lacks the key → blocking error."""
|
||||
client.templates[7] = _annotated_template_client().templates[7]
|
||||
resolver_stub.mappings.clear()
|
||||
report = await setup.validate_protocol_template("experiments_templates", 7)
|
||||
assert report.ok is False
|
||||
errors = [i for i in report.issues if i.severity == "error"]
|
||||
assert any(i.resource_key == "ethanol_absolute" for i in errors)
|
||||
|
||||
|
||||
async def test_validate_ok_when_annotation_and_mapping_are_fine(setup, client, resolver_stub):
|
||||
client.templates[7] = _annotated_template_client().templates[7]
|
||||
report = await setup.validate_protocol_template("experiments_templates", 7)
|
||||
assert report.ok is False # the broken json step must block
|
||||
# fix the broken step, then expect a clean report
|
||||
client.templates[7].steps[1].body = "Clean step without annotation"
|
||||
report = await setup.validate_protocol_template("experiments_templates", 7)
|
||||
assert report.ok is True
|
||||
assert report.issues == []
|
||||
|
||||
|
||||
async def test_validate_reports_annotation_error_with_step_id(setup, client):
|
||||
client.templates[7] = _annotated_template_client().templates[7]
|
||||
report = await setup.validate_protocol_template("experiments_templates", 7)
|
||||
issues = [i for i in report.issues if i.step_id == 42]
|
||||
assert issues and issues[0].severity == "error"
|
||||
|
||||
|
||||
async def test_validate_entity_type_is_mirrored_in_report(setup, client):
|
||||
client.templates[7] = _annotated_template_client().templates[7]
|
||||
report = await setup.validate_protocol_template("experiments_templates", 7)
|
||||
assert report.entity_type == "experiments_templates"
|
||||
assert report.entity_id == 7
|
||||
|
||||
|
||||
async def test_validate_experiment_steps_too(setup, client, resolver_stub):
|
||||
"""validate works on live experiments, not only templates."""
|
||||
resolver_stub.mappings.clear()
|
||||
report = await setup.validate_protocol_template("experiments", 123)
|
||||
assert report.entity_type == "experiments"
|
||||
assert report.ok is False
|
||||
|
||||
|
||||
async def test_validate_unknown_entity_fails_cleanly(setup):
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await setup.validate_protocol_template("experiments", 9999)
|
||||
assert not isinstance(excinfo.value, NotImplementedError)
|
||||
Reference in New Issue
Block a user