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

354 lines
12 KiB
Python
Raw Normal View History

"""HTTP contract tests for surgery client API (dependency overrides, no real DB on lifespan)."""
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
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, get_voice_terminal_hub
from app.services.voice_terminal_hub import (
VoiceTerminalHub,
assign_voice_terminal_after_recording_started,
)
from app.config import Settings
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)
hub = VoiceTerminalHub(Settings())
app.dependency_overrides[get_voice_terminal_hub] = lambda: hub
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_assign_voice_terminal_helper_matches_start_surgery_behavior(
tmp_path: Path,
) -> None:
site = {
"video_rtsp_urls": {"cam1": "rtsp://x/c1"},
"voice_or_room_bindings": [
{
"or_room_id": "R1",
"camera_ids": ["cam1"],
"voice_terminal_id": "TERM-X",
}
],
}
p = tmp_path / "site.json"
p.write_text(json.dumps(site), encoding="utf-8")
hub = VoiceTerminalHub(Settings(or_site_config_json_file=str(p)))
set_vtid = MagicMock()
async def _run() -> None:
await assign_voice_terminal_after_recording_started(
hub,
surgery_id="123456",
camera_ids=["cam1"],
set_voice_terminal_id=set_vtid,
)
asyncio.run(_run())
set_vtid.assert_called_once_with("123456", "TERM-X")
assert hub.get_assignment("TERM-X") == "123456"
def test_start_surgery_notifies_voice_terminal_when_binding_matches(
api_app: FastAPI, instant_sleep: None, tmp_path: Path
) -> None:
site = {
"video_rtsp_urls": {"cam1": "rtsp://x/c1", "cam2": "rtsp://x/c2"},
"voice_or_room_bindings": [
{
"or_room_id": "R1",
"camera_ids": ["cam1", "cam2"],
"voice_terminal_id": "TERM-OR1",
}
],
}
p = tmp_path / "site.json"
p.write_text(json.dumps(site), encoding="utf-8")
hub = VoiceTerminalHub(Settings(or_site_config_json_file=str(p)))
api_app.dependency_overrides[get_voice_terminal_hub] = lambda: hub
pipeline = MagicMock()
pipeline.start_recording = AsyncMock(return_value=None)
pipeline.set_voice_terminal_id = MagicMock()
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": ["cam2", "cam1"],
"candidate_consumables": ["纱布"],
},
)
assert r.status_code == 200
pipeline.set_voice_terminal_id.assert_called_once_with("123456", "TERM-OR1")
assert hub.get_assignment("TERM-OR1") == "123456"
def test_voice_terminal_assignment_get(api_app: FastAPI) -> None:
hub = api_app.dependency_overrides[get_voice_terminal_hub]()
asyncio.run(hub.notify_start("t-assign", "999999"))
client = TestClient(api_app)
r = client.get("/client/voice-terminals/t-assign/assignment")
assert r.status_code == 200
assert r.json() == {
"voice_terminal_id": "t-assign",
"active_surgery_id": "999999",
}
def test_end_surgery_notifies_voice_terminal(
api_app: FastAPI, instant_sleep: None
) -> None:
hub = api_app.dependency_overrides[get_voice_terminal_hub]()
asyncio.run(hub.notify_start("t-end", "123456"))
pipeline = MagicMock()
pipeline.stop_recording = AsyncMock(return_value="t-end")
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
assert hub.get_assignment("t-end") is None
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="纱布",
qty=1,
doctor_id="vision",
timestamp=ts,
),
]
)
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 list(body["details"][0].keys()) == [
"item_id",
"item_name",
"qty",
"doctor_id",
"timestamp",
]
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_get_result_503_empty_details(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.get_consumption_details_for_client = AsyncMock(return_value=[])
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="请确认",
prompt_audio_mp3_base64="//uQ",
options=[],
model_top1_label="x",
model_top1_confidence=0.4,
created_at=ts,
)
pipeline_ok = MagicMock()
pipeline_ok.get_pending_confirmation_for_client = AsyncMock(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
body_ok = r.json()
assert body_ok["confirmation_id"] == "cid"
assert body_ok["prompt_audio_mp3_base64"] == "//uQ"
pipeline_none = MagicMock()
pipeline_none.get_pending_confirmation_for_client = AsyncMock(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"