Files
operating-room-monitor-server/backend/tests/test_api_contract.py
op 3979e13ca9 Prevent concurrent RTSP recording on the same camera.
Reserve camera ownership under registry lock before spawning ffmpeg, return 409 for conflicts without retry, and transcode slices to 1080p H.264 for more stable playback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 16:32:55 +08:00

468 lines
16 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, patch
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_start_surgery_accepts_candidate_consumables_export_objects(
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)
consumables = [
{"消耗品编号": "14764-2-4", "名称": "一次性使用手术单"},
{"消耗品编号": "8036-5-22", "名称": "一次性使用医疗卫生用品"},
]
r = client.post(
"/client/surgeries/start",
json={
"surgery_id": "123456",
"camera_ids": ["cam1"],
"candidate_consumables": consumables,
},
)
assert r.status_code == 200
pipeline.start_recording.assert_awaited_once()
call_kw = pipeline.start_recording.call_args
assert call_kw[0][2] == ["一次性使用手术单", "一次性使用医疗卫生用品"]
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"
async def test_voice_terminal_hub_notify_pending_head_payload() -> None:
payload = SurgeryPendingConfirmationResponse(
surgery_id="123456",
confirmation_id="cid-a",
pending_queue_length=1,
pending_queue_position=1,
pending_cumulative_ordinal=1,
prompt_text="请确认",
prompt_audio_mp3_base64="QQ==",
options=[],
model_top1_label="x",
model_top1_confidence=0.5,
created_at=datetime.now(timezone.utc),
)
fetcher = AsyncMock(return_value=payload)
hub = VoiceTerminalHub(Settings(), pending_head_fetcher=fetcher)
with patch.object(hub, "_broadcast", new_callable=AsyncMock) as bc:
await hub.notify_pending_head("TERM-1", "123456")
fetcher.assert_awaited_once_with("123456")
sent = bc.await_args[0][1]
assert sent["type"] == "voice_pending"
assert sent["confirmation_id"] == "cid-a"
assert sent["surgery_id"] == "123456"
async def test_voice_terminal_hub_notify_pending_head_empty() -> None:
fetcher = AsyncMock(return_value=None)
hub = VoiceTerminalHub(Settings(), pending_head_fetcher=fetcher)
with patch.object(hub, "_broadcast", new_callable=AsyncMock) as bc:
await hub.notify_pending_head("TERM-1", "123456")
fetcher.assert_awaited_once_with("123456")
assert bc.await_args[0][1] == {
"type": "voice_pending_empty",
"surgery_id": "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_409_when_camera_already_recording(
api_app: FastAPI,
instant_sleep: None,
) -> None:
pipeline = MagicMock()
pipeline.start_recording = AsyncMock(
side_effect=SurgeryPipelineError(
"CAMERA_ALREADY_RECORDING",
"机位 or-cam-03 正被手术 100155 录制,请先结束该场次再开录。",
)
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/start",
json={"surgery_id": "100003", "camera_ids": ["or-cam-03"], "candidate_consumables": []},
)
assert r.status_code == 409
d = r.json()["detail"]
assert d["code"] == "CAMERA_ALREADY_RECORDING"
assert d["surgery_id"] == "100003"
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="纱布",
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)
pipeline.classify_result_unavailable = AsyncMock(
return_value=("RESULT_NOT_READY", "手术未开始,请先调用开始手术接口。")
)
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=[])
pipeline.classify_result_unavailable = AsyncMock(
return_value=("RESULT_NOT_READY", "手术已结束,当前无消耗明细。")
)
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",
pending_queue_length=1,
pending_queue_position=1,
pending_cumulative_ordinal=1,
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["pending_queue_length"] == 1
assert body_ok["pending_queue_position"] == 1
assert body_ok["pending_cumulative_ordinal"] == 1
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["status"] == "accepted"
assert body["resolved_label"] == "纱布"
assert body["rejected"] is False
assert body["asr_text"] == "第一个"
def test_resolve_voice_recoverable_error_returns_422(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_audio = AsyncMock(
side_effect=SurgeryPipelineError(
"VOICE_ASR_FAILED",
"asr_err_3301: speech quality error.",
)
)
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 == 422
body = r.json()
assert body["detail"]["code"] == "VOICE_ASR_FAILED"
assert "3301" in body["detail"]["message"]
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"