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

420 lines
14 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
2026-04-28 10:41:48 +08:00
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_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"
2026-04-28 10:41:48 +08:00
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_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",
2026-04-28 10:41:48 +08:00
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"
2026-04-28 10:41:48 +08:00
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()
2026-04-28 10:41:48 +08:00
assert body["status"] == "accepted"
assert body["error_code"] is None
assert body["resolved_label"] == "纱布"
assert body["rejected"] is False
assert body["asr_text"] == "第一个"
2026-04-28 10:41:48 +08:00
def test_resolve_voice_recoverable_error_returns_200_failed(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 == 200
body = r.json()
assert body["status"] == "failed"
assert body["error_code"] == "VOICE_ASR_FAILED"
assert "3301" in body["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"