"""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")