306 lines
10 KiB
Python
306 lines
10 KiB
Python
|
|
"""集成测试:通过真实的 ``create_app()`` + ``TestClient`` 走通 start/pending/resolve/end/result 全链路。
|
|||
|
|
|
|||
|
|
与 ``tests/test_api_contract.py`` 不同,这里不用 ``dependency_overrides`` 替换整个
|
|||
|
|
``SurgeryPipeline``;而是通过 ``app.state.container`` 注入一个「stubbed session manager」,
|
|||
|
|
其余 pipeline/voice/repository 组件都保持真实实现,并使用 in-memory SQLite 作为会话工厂。
|
|||
|
|
|
|||
|
|
这样可以覆盖:
|
|||
|
|
1. `create_app()` 的 CORS / demo_orchestrator 路径挂载、lifespan 启动/关闭流程。
|
|||
|
|
2. API → Pipeline → Session Registry → Repository 的真实调用链。
|
|||
|
|
3. durable fallback 目录被 ``ArchivePersister`` 写入/清理的真实路径。
|
|||
|
|
|
|||
|
|
所有会与外界交互的边界(RTSP/海康/MinIO/百度)通过容器中的 stub 对象隔离。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
from collections.abc import AsyncGenerator
|
|||
|
|
from datetime import datetime, timezone
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
import pytest_asyncio
|
|||
|
|
from fastapi.testclient import TestClient
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|||
|
|
|
|||
|
|
import app.db.models # noqa: F401 # register ORM tables on Base.metadata
|
|||
|
|
import main as main_module
|
|||
|
|
from app.db.base import Base
|
|||
|
|
from app.dependencies import AppContainer, build_container
|
|||
|
|
from app.domain.consumption import SurgeryConsumptionStored
|
|||
|
|
from app.services.video.session_registry import (
|
|||
|
|
PendingConsumableConfirmation,
|
|||
|
|
RunningSurgery,
|
|||
|
|
SurgerySessionState,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _StubCameraSessionManager:
|
|||
|
|
"""仅实现测试链路必需方法;其余方法委托 ``_registry`` / ``_archive``(由真实组件提供)。
|
|||
|
|
|
|||
|
|
这样 ``SurgeryPipeline`` / ``VoiceConfirmationService`` 读到的接口与真实 CameraSessionManager
|
|||
|
|
等价,不需要真实 RTSP 或推理流水线。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(self, real: Any) -> None:
|
|||
|
|
self._real = real
|
|||
|
|
self._registry = real._registry
|
|||
|
|
self._archive = real._archive
|
|||
|
|
|
|||
|
|
async def start_surgery(
|
|||
|
|
self,
|
|||
|
|
surgery_id: str,
|
|||
|
|
camera_ids: list[str],
|
|||
|
|
candidate_consumables: list[str],
|
|||
|
|
) -> None:
|
|||
|
|
if self._registry.has_active(surgery_id):
|
|||
|
|
from app.surgery_errors import SurgeryPipelineError
|
|||
|
|
|
|||
|
|
raise SurgeryPipelineError(
|
|||
|
|
"RECORDING_CANNOT_START",
|
|||
|
|
"该手术已在录制中,请勿重复开始。",
|
|||
|
|
)
|
|||
|
|
state = SurgerySessionState(
|
|||
|
|
candidate_consumables=list(candidate_consumables),
|
|||
|
|
name_to_code={},
|
|||
|
|
)
|
|||
|
|
state.ready.set()
|
|||
|
|
run = RunningSurgery(
|
|||
|
|
stop_event=asyncio.Event(), state=state, tasks=[]
|
|||
|
|
)
|
|||
|
|
await self._registry.register(surgery_id, run)
|
|||
|
|
|
|||
|
|
async def stop_surgery(
|
|||
|
|
self, surgery_id: str, *, require_active: bool = True
|
|||
|
|
) -> None:
|
|||
|
|
run = await self._registry.unregister(surgery_id)
|
|||
|
|
if run is None:
|
|||
|
|
if require_active:
|
|||
|
|
from app.surgery_errors import SurgeryPipelineError
|
|||
|
|
|
|||
|
|
raise SurgeryPipelineError(
|
|||
|
|
"RECORDING_NOT_STOPPED",
|
|||
|
|
"停录未能完成:当前没有该手术的活跃录制会话。",
|
|||
|
|
)
|
|||
|
|
return
|
|||
|
|
details = list(run.state.details)
|
|||
|
|
await self._archive.persist_or_archive(surgery_id, details)
|
|||
|
|
|
|||
|
|
def __getattr__(self, name: str) -> Any:
|
|||
|
|
return getattr(self._real, name)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class _StubVoiceService:
|
|||
|
|
"""屏蔽 MinIO/百度调用;保留 ``synthesize_prompt_to_mp3`` 与 ``resolve_from_wav`` 的最小语义。"""
|
|||
|
|
|
|||
|
|
def __init__(self, real: Any) -> None:
|
|||
|
|
self._real = real
|
|||
|
|
self._sessions = real._sessions
|
|||
|
|
|
|||
|
|
def synthesize_prompt_to_mp3(self, prompt_text: str) -> bytes:
|
|||
|
|
return b"MP3-FAKE-" + prompt_text.encode("utf-8", errors="replace")
|
|||
|
|
|
|||
|
|
async def resolve_from_wav(
|
|||
|
|
self,
|
|||
|
|
*,
|
|||
|
|
surgery_id: str,
|
|||
|
|
confirmation_id: str,
|
|||
|
|
wav_bytes: bytes,
|
|||
|
|
filename: str,
|
|||
|
|
content_type: str | None,
|
|||
|
|
) -> Any:
|
|||
|
|
from app.services.voice_resolution import VoiceResolveResult
|
|||
|
|
from app.surgery_errors import SurgeryPipelineError
|
|||
|
|
|
|||
|
|
pending = self._sessions.get_pending_confirmation_by_id(
|
|||
|
|
surgery_id, confirmation_id
|
|||
|
|
)
|
|||
|
|
if pending is None:
|
|||
|
|
raise SurgeryPipelineError(
|
|||
|
|
"CONFIRMATION_NOT_FOUND",
|
|||
|
|
"未找到该待确认项或已处理。",
|
|||
|
|
)
|
|||
|
|
label = (pending.options[0][0] if pending.options else None) or (
|
|||
|
|
pending.model_top1_label
|
|||
|
|
)
|
|||
|
|
await self._sessions.resolve_pending_confirmation(
|
|||
|
|
surgery_id,
|
|||
|
|
confirmation_id,
|
|||
|
|
chosen_label=label,
|
|||
|
|
rejected=False,
|
|||
|
|
)
|
|||
|
|
return VoiceResolveResult(
|
|||
|
|
resolved_label=label,
|
|||
|
|
rejected=False,
|
|||
|
|
asr_text="第一个",
|
|||
|
|
audio_object_key=f"stub/{surgery_id}/{confirmation_id}.wav",
|
|||
|
|
message="确认成功(stub)",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest_asyncio.fixture
|
|||
|
|
async def sqlite_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
|
|||
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|||
|
|
async with engine.begin() as conn:
|
|||
|
|
await conn.run_sync(Base.metadata.create_all)
|
|||
|
|
factory = async_sessionmaker(
|
|||
|
|
engine,
|
|||
|
|
class_=AsyncSession,
|
|||
|
|
expire_on_commit=False,
|
|||
|
|
autoflush=False,
|
|||
|
|
autobegin=False,
|
|||
|
|
)
|
|||
|
|
yield factory
|
|||
|
|
await engine.dispose()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def integration_client(
|
|||
|
|
monkeypatch: pytest.MonkeyPatch,
|
|||
|
|
sqlite_factory: async_sessionmaker[AsyncSession],
|
|||
|
|
tmp_path,
|
|||
|
|
) -> TestClient:
|
|||
|
|
async def _noop() -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(main_module, "check_database", _noop)
|
|||
|
|
monkeypatch.setattr(main_module, "init_db_schema", _noop)
|
|||
|
|
|
|||
|
|
class _FakeEngine:
|
|||
|
|
async def dispose(self) -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(main_module, "engine", _FakeEngine())
|
|||
|
|
|
|||
|
|
from app.config import settings as real_settings
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(
|
|||
|
|
real_settings,
|
|||
|
|
"archive_persist_durable_fallback_dir",
|
|||
|
|
str(tmp_path / "pending_archive"),
|
|||
|
|
)
|
|||
|
|
monkeypatch.setattr(real_settings, "auto_create_schema", False)
|
|||
|
|
|
|||
|
|
def _stubbed_build_container(*args, **kwargs) -> AppContainer:
|
|||
|
|
container = build_container(real_settings, session_factory=sqlite_factory)
|
|||
|
|
container.camera_session_manager = _StubCameraSessionManager(
|
|||
|
|
container.camera_session_manager
|
|||
|
|
)
|
|||
|
|
container.surgery_pipeline._sessions = container.camera_session_manager
|
|||
|
|
container.voice_confirmation_service._sessions = (
|
|||
|
|
container.camera_session_manager._registry
|
|||
|
|
)
|
|||
|
|
container.surgery_pipeline._voice = _StubVoiceService(
|
|||
|
|
container.surgery_pipeline
|
|||
|
|
)
|
|||
|
|
return container
|
|||
|
|
|
|||
|
|
monkeypatch.setattr(main_module, "build_container", _stubbed_build_container)
|
|||
|
|
|
|||
|
|
async def _instant_sleep(_d: float) -> None:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
monkeypatch.setattr("app.api.asyncio.sleep", _instant_sleep)
|
|||
|
|
|
|||
|
|
app = main_module.create_app()
|
|||
|
|
with TestClient(app) as client:
|
|||
|
|
yield client
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _enqueue_pending(
|
|||
|
|
client: TestClient, *, surgery_id: str
|
|||
|
|
) -> str:
|
|||
|
|
container: AppContainer = client.app.state.container
|
|||
|
|
run = container.camera_session_manager._registry.get_running(surgery_id)
|
|||
|
|
assert run is not None
|
|||
|
|
cid = "cid-integration-1"
|
|||
|
|
pending = PendingConsumableConfirmation(
|
|||
|
|
id=cid,
|
|||
|
|
status="pending",
|
|||
|
|
options=[("纱布", 0.42)],
|
|||
|
|
prompt_text="请确认:是否为纱布",
|
|||
|
|
created_at=datetime.now(timezone.utc),
|
|||
|
|
model_top1_label="纱布",
|
|||
|
|
model_top1_confidence=0.42,
|
|||
|
|
)
|
|||
|
|
run.state.pending_fifo.append(cid)
|
|||
|
|
run.state.pending_by_id[cid] = pending
|
|||
|
|
return cid
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_full_flow_start_pending_resolve_end_result(
|
|||
|
|
integration_client: TestClient,
|
|||
|
|
) -> None:
|
|||
|
|
client = integration_client
|
|||
|
|
surgery_id = "100001"
|
|||
|
|
|
|||
|
|
r = client.post(
|
|||
|
|
"/client/surgeries/start",
|
|||
|
|
json={
|
|||
|
|
"surgery_id": surgery_id,
|
|||
|
|
"camera_ids": ["cam1"],
|
|||
|
|
"candidate_consumables": ["纱布"],
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
assert r.status_code == 200, r.text
|
|||
|
|
assert r.json()["status"] == "accepted"
|
|||
|
|
|
|||
|
|
r2 = client.get(f"/client/surgeries/{surgery_id}/pending-confirmation")
|
|||
|
|
assert r2.status_code == 404, r2.text
|
|||
|
|
|
|||
|
|
cid = _enqueue_pending(client, surgery_id=surgery_id)
|
|||
|
|
|
|||
|
|
r3 = client.get(f"/client/surgeries/{surgery_id}/pending-confirmation")
|
|||
|
|
assert r3.status_code == 200, r3.text
|
|||
|
|
body3 = r3.json()
|
|||
|
|
assert body3["confirmation_id"] == cid
|
|||
|
|
import base64
|
|||
|
|
|
|||
|
|
decoded = base64.b64decode(body3["prompt_audio_mp3_base64"].encode("ascii"))
|
|||
|
|
assert decoded.startswith(b"MP3-FAKE-")
|
|||
|
|
|
|||
|
|
r4 = client.post(
|
|||
|
|
f"/client/surgeries/{surgery_id}/pending-confirmation/{cid}/resolve",
|
|||
|
|
files={"audio": ("voice.wav", b"RIFFxxxx", "audio/wav")},
|
|||
|
|
)
|
|||
|
|
assert r4.status_code == 200, r4.text
|
|||
|
|
body4 = r4.json()
|
|||
|
|
assert body4["resolved_label"] == "纱布"
|
|||
|
|
assert body4["rejected"] is False
|
|||
|
|
|
|||
|
|
r5 = client.get(f"/client/surgeries/{surgery_id}/result")
|
|||
|
|
assert r5.status_code == 200, r5.text
|
|||
|
|
body5 = r5.json()
|
|||
|
|
assert body5["surgery_id"] == surgery_id
|
|||
|
|
assert len(body5["details"]) == 1
|
|||
|
|
row = body5["details"][0]
|
|||
|
|
assert row["item_name"] == "纱布"
|
|||
|
|
assert row["qty"] == 1
|
|||
|
|
assert row["doctor_id"] == "voice"
|
|||
|
|
|
|||
|
|
r6 = client.post(
|
|||
|
|
"/client/surgeries/end", json={"surgery_id": surgery_id}
|
|||
|
|
)
|
|||
|
|
assert r6.status_code == 200, r6.text
|
|||
|
|
|
|||
|
|
r7 = client.get(f"/client/surgeries/{surgery_id}/result")
|
|||
|
|
assert r7.status_code == 200, r7.text
|
|||
|
|
body7 = r7.json()
|
|||
|
|
assert len(body7["details"]) == 1
|
|||
|
|
assert body7["details"][0]["item_name"] == "纱布"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_result_not_ready_before_start(integration_client: TestClient) -> None:
|
|||
|
|
r = integration_client.get("/client/surgeries/999999/result")
|
|||
|
|
assert r.status_code == 503
|
|||
|
|
assert r.json()["detail"]["code"] == "RESULT_NOT_READY"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_health_endpoint_ok_via_real_app(integration_client: TestClient) -> None:
|
|||
|
|
r = integration_client.get("/health")
|
|||
|
|
assert r.status_code == 200
|
|||
|
|
body = r.json()
|
|||
|
|
assert body["status"] == "ok"
|
|||
|
|
assert body["database"] == "connected"
|