Files
operating-room-monitor-server/tests/test_api_contract.py

258 lines
8.8 KiB
Python
Raw Normal View History

"""HTTP contract tests for surgery client API (dependency overrides, no real DB on lifespan)."""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import router as api_router
from app.dependencies import get_surgery_pipeline
from app.schemas import (
SurgeryConsumptionDetail,
SurgeryPendingConfirmationResponse,
)
from app.services.voice_resolution import VoiceResolveResult
from app.surgery_errors import SurgeryPipelineError
@pytest.fixture
def instant_sleep(monkeypatch: pytest.MonkeyPatch) -> None:
async def _noop(_delay: float) -> None:
return None
monkeypatch.setattr("app.api.asyncio.sleep", _noop)
@pytest.fixture
def api_app(monkeypatch: pytest.MonkeyPatch) -> FastAPI:
async def _check_db_ok() -> None:
return None
monkeypatch.setattr("app.api.check_database", _check_db_ok)
app = FastAPI()
app.include_router(api_router)
return app
def test_health_ok(api_app: FastAPI) -> None:
client = TestClient(api_app)
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_start_surgery_accepted(api_app: FastAPI, instant_sleep: None) -> None:
pipeline = MagicMock()
pipeline.start_recording = AsyncMock(return_value=None)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/start",
json={
"surgery_id": "123456",
"camera_ids": ["cam1"],
"candidate_consumables": ["纱布"],
},
)
assert r.status_code == 200
body = r.json()
assert body["surgery_id"] == "123456"
assert body["status"] == "accepted"
pipeline.start_recording.assert_awaited_once()
def test_start_surgery_503_on_pipeline_error(
api_app: FastAPI, instant_sleep: None
) -> None:
pipeline = MagicMock()
pipeline.start_recording = AsyncMock(
side_effect=SurgeryPipelineError("RECORDING_CANNOT_START", "cannot")
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/start",
json={"surgery_id": "123456", "camera_ids": ["c1"], "candidate_consumables": []},
)
assert r.status_code == 503
d = r.json()["detail"]
assert d["code"] == "RECORDING_CANNOT_START"
assert d["surgery_id"] == "123456"
def test_start_surgery_422_invalid_surgery_id(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.start_recording = AsyncMock()
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/start",
json={"surgery_id": "12", "camera_ids": ["c1"], "candidate_consumables": []},
)
assert r.status_code == 422
def test_end_surgery_accepted(api_app: FastAPI, instant_sleep: None) -> None:
pipeline = MagicMock()
pipeline.stop_recording = AsyncMock(return_value=None)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post("/client/surgeries/end", json={"surgery_id": "123456"})
assert r.status_code == 200
pipeline.stop_recording.assert_awaited_once_with("123456")
def test_get_result_200(api_app: FastAPI) -> None:
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
pipeline = MagicMock()
pipeline.get_consumption_details_for_client = AsyncMock(
return_value=[
SurgeryConsumptionDetail(
item_id="纱布",
item_name="纱布",
quantity=1,
doctor_id="vision",
timestamp=ts,
source="vision",
),
]
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.get("/client/surgeries/123456/result")
assert r.status_code == 200
body = r.json()
assert body["surgery_id"] == "123456"
assert len(body["details"]) == 1
assert body["summary"][0]["total_quantity"] == 1
def test_get_result_503_not_ready(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.get_consumption_details_for_client = AsyncMock(return_value=None)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.get("/client/surgeries/123456/result")
assert r.status_code == 503
assert r.json()["detail"]["code"] == "RESULT_NOT_READY"
def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None:
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
payload = SurgeryPendingConfirmationResponse(
surgery_id="123456",
confirmation_id="cid",
prompt_text="请确认",
options=[],
model_top1_label="x",
model_top1_confidence=0.4,
created_at=ts,
)
pipeline_ok = MagicMock()
pipeline_ok.get_pending_confirmation_for_client = MagicMock(return_value=payload)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline_ok
client = TestClient(api_app)
r = client.get("/client/surgeries/123456/pending-confirmation")
assert r.status_code == 200
assert r.json()["confirmation_id"] == "cid"
pipeline_none = MagicMock()
pipeline_none.get_pending_confirmation_for_client = MagicMock(return_value=None)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline_none
client2 = TestClient(api_app)
r2 = client2.get("/client/surgeries/123456/pending-confirmation")
assert r2.status_code == 404
assert r2.json()["detail"]["code"] == "NO_PENDING_CONFIRMATION"
def test_resolve_empty_audio_422(api_app: FastAPI) -> None:
pipeline = MagicMock()
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/123456/pending-confirmation/cid/resolve",
files={"audio": ("a.wav", b"", "audio/wav")},
)
assert r.status_code == 422
assert r.json()["detail"]["code"] == "VOICE_AUDIO_INVALID"
def test_resolve_non_wav_422(api_app: FastAPI) -> None:
pipeline = MagicMock()
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/123456/pending-confirmation/cid/resolve",
files={"audio": ("a.mp3", b"abc", "audio/mpeg")},
)
assert r.status_code == 422
def test_resolve_200(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_audio = AsyncMock(
return_value=VoiceResolveResult(
resolved_label="纱布",
rejected=False,
asr_text="第一个",
audio_object_key="k.wav",
message="ok",
)
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/123456/pending-confirmation/cid/resolve",
files={"audio": ("a.wav", b"RIFF", "audio/wav")},
)
assert r.status_code == 200
body = r.json()
assert body["resolved_label"] == "纱布"
assert body["rejected"] is False
assert body["asr_text"] == "第一个"
def test_resolve_maps_surgery_pipeline_error_to_http(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_audio = AsyncMock(
side_effect=SurgeryPipelineError("CONFIRMATION_NOT_FOUND", "missing")
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/123456/pending-confirmation/cid/resolve",
files={"audio": ("a.wav", b"x", "audio/wav")},
)
assert r.status_code == 404
assert r.json()["detail"]["code"] == "CONFIRMATION_NOT_FOUND"
def test_internal_voice_status_404_and_200(api_app: FastAPI) -> None:
p_none = MagicMock()
p_none.voice_status = MagicMock(return_value=None)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: p_none
client = TestClient(api_app)
r = client.get("/internal/surgeries/123456/voice-status")
assert r.status_code == 404
p_ok = MagicMock()
p_ok.voice_status = MagicMock(
return_value={
"voice_enabled": True,
"pending_queue_approx": 2,
"last_prompt_snippet": "hi",
"last_asr_text": "纱布",
"last_error": None,
}
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: p_ok
client2 = TestClient(api_app)
r2 = client2.get("/internal/surgeries/123456/voice-status")
assert r2.status_code == 200
assert r2.json()["pending_queue_approx"] == 2