165 lines
5.5 KiB
Python
165 lines
5.5 KiB
Python
"""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
|