Initial version

This commit is contained in:
Tom Kasper
2026-08-30 20:44:00 +02:00
commit 9aa2da72b5
612 changed files with 64514 additions and 0 deletions
+184
View File
@@ -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")