Files
operating-room-monitor-server/tests/test_api_contract.py
Kevin 6b3adb4ad8 feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调
- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings)
- VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify
- 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS
- 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长
- 新增/调整契约与绑定测试,文档与示例配置同步

Made-with: Cursor
2026-04-27 11:21:16 +08:00

354 lines
12 KiB
Python

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