185 lines
6.0 KiB
Python
185 lines
6.0 KiB
Python
"""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")
|