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