"""HTTP contract tests for surgery client API (dependency overrides, no real DB on lifespan).""" from __future__ import annotations from datetime import datetime, timezone 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 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) 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_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="纱布", quantity=1, doctor_id="vision", timestamp=ts, source="vision", ), ] ) 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 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_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="请确认", options=[], model_top1_label="x", model_top1_confidence=0.4, created_at=ts, ) pipeline_ok = MagicMock() pipeline_ok.get_pending_confirmation_for_client = MagicMock(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 assert r.json()["confirmation_id"] == "cid" pipeline_none = MagicMock() pipeline_none.get_pending_confirmation_for_client = MagicMock(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" def test_internal_voice_status_404_and_200(api_app: FastAPI) -> None: p_none = MagicMock() p_none.voice_status = MagicMock(return_value=None) api_app.dependency_overrides[get_surgery_pipeline] = lambda: p_none client = TestClient(api_app) r = client.get("/internal/surgeries/123456/voice-status") assert r.status_code == 404 p_ok = MagicMock() p_ok.voice_status = MagicMock( return_value={ "voice_enabled": True, "pending_queue_approx": 2, "last_prompt_snippet": "hi", "last_asr_text": "纱布", "last_error": None, } ) api_app.dependency_overrides[get_surgery_pipeline] = lambda: p_ok client2 = TestClient(api_app) r2 = client2.get("/internal/surgeries/123456/voice-status") assert r2.status_code == 200 assert r2.json()["pending_queue_approx"] == 2