重组为 backend/clients/docs 三层结构,并清理 git 污染。
将后端迁入 backend/,完善根目录 .gitignore,删除误提交的 .mypy_cache 缓存文件。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
439
backend/tests/test_api_contract.py
Normal file
439
backend/tests/test_api_contract.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""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_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",
|
||||
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["error_code"] is None
|
||||
assert body["resolved_label"] == "纱布"
|
||||
assert body["rejected"] is False
|
||||
assert body["asr_text"] == "第一个"
|
||||
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user