968 lines
33 KiB
Python
968 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
SOURCE_ROOT = PROJECT_ROOT / "src"
|
|
if str(SOURCE_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(SOURCE_ROOT))
|
|
|
|
from doctor_workstation.video.launcher import ( # noqa: E402
|
|
BackendMode,
|
|
VideoCallLauncher,
|
|
VideoCallRequest,
|
|
VideoTicketError,
|
|
normalize_backend_ticket,
|
|
)
|
|
from doctor_workstation.video.lifecycle import OrderedCallLifecycle # noqa: E402
|
|
from doctor_workstation.video.security import ( # noqa: E402
|
|
TrustedDocumentError,
|
|
TrustedDocumentPolicy,
|
|
)
|
|
|
|
|
|
def test_companion_uses_legacy_safe_transcription_session_identity() -> None:
|
|
"""Generated session IDs stay below 32 chars so upgraded databases cannot truncate."""
|
|
|
|
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
function_source = source.split("function newTranscriptionSessionId", 1)[1].split(
|
|
"function requestTranscriptionStart", 1
|
|
)[0]
|
|
|
|
assert "replaceAll('-', '')" in function_source
|
|
assert ".slice(0, 28)" in function_source
|
|
assert "return `tr-${" in function_source
|
|
|
|
|
|
def test_companion_archives_cloud_video_local_mixed_audio_and_transcript() -> None:
|
|
"""A connected call starts three independent artifacts before hangup."""
|
|
|
|
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
assert "context.createMediaStreamDestination()" in source
|
|
assert "cloud.getAudioTrack({ processed: true })" in source
|
|
assert "userId: activeConfig.targetUserId" in source
|
|
assert "new MediaRecorder(destination.stream" in source
|
|
assert "recorder.start(1000)" in source
|
|
assert "bridge.startLocalAudioRecording(sessionId, mimeType)" in source
|
|
assert "bridge.appendLocalAudioChunk(" in source
|
|
assert "bridge.finishLocalAudioRecording(sessionId, totalBytes)" in source
|
|
assert "operations.push(stopLocalRecording())" in source
|
|
assert "operations.push(stopTranscription('completed'))" in source
|
|
assert "Promise.allSettled(operations)" in source
|
|
assert "腾讯云混流视频、本机语音录音和实时转写均已启动" in source
|
|
|
|
|
|
def test_companion_watches_room_id_for_the_entire_call_cycle() -> None:
|
|
"""A slowly-created TRTC room must still bind to the exact call record."""
|
|
|
|
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
room_source = source.split("function readRoomId", 1)[1].split(
|
|
"function handleStatusChanged", 1
|
|
)[0]
|
|
|
|
assert "TUIStore.watch(StoreName.CALL, roomIdWatchOptions)" in room_source
|
|
assert "[NAME.ROOM_ID]: handleRoomIdChanged" in room_source
|
|
assert "cycle === callCycleGeneration" in room_source
|
|
assert "while (activeConfig && !endNotified" in room_source
|
|
assert "attempt < 40" not in room_source
|
|
assert "diagnosisId: activeConfig.diagnosisId" in room_source
|
|
|
|
|
|
def test_room_binding_is_acknowledged_and_transcriber_room_is_a_fallback() -> None:
|
|
companion_source = (
|
|
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
|
|
).read_text(encoding="utf-8")
|
|
window_source = (
|
|
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "observeRoomId(roomId)" in companion_source
|
|
assert "onRealtimeTranscriberStarted: (roomId)" in companion_source
|
|
assert "function roomBindingResult(" in companion_source
|
|
assert "if (boundRoomId) return" in companion_source
|
|
assert "roomBindingResult?.(" in window_source
|
|
|
|
|
|
def test_im_conversation_renders_deduplicated_video_call_status_timeline() -> None:
|
|
"""Video lifecycle feedback belongs in the IM timeline as local status events."""
|
|
|
|
companion_root = PROJECT_ROOT / "video_companion" / "src"
|
|
source = (companion_root / "main.ts").read_text(encoding="utf-8")
|
|
app_source = (companion_root / "App.vue").read_text(encoding="utf-8")
|
|
styles = (companion_root / "style.css").read_text(encoding="utf-8")
|
|
|
|
timeline_source = source.split("function appendVideoCallStatus", 1)[1].split(
|
|
"function onMessageReceived", 1
|
|
)[0]
|
|
assert "activeConfig.mode !== 'chat'" in timeline_source
|
|
assert "local-video-call-${callCycleGeneration}-${callStatus}" in timeline_source
|
|
assert "findIndex((item) => item.id === id)" in timeline_source
|
|
assert "appendVideoCallStatus('starting', '正在创建安全视频通话')" in source
|
|
assert "appendVideoCallStatus('dialing', '正在呼叫患者')" in source
|
|
assert "appendVideoCallStatus('connected', '视频通话已接通')" in source
|
|
assert "appendVideoCallStatus('ended', '视频通话已结束')" in source
|
|
assert "appendVideoCallStatus('failed', `视频通话发起失败:${message}`)" in source
|
|
assert "message.type === 'call-status'" in app_source
|
|
assert 'class="call-status-event"' in app_source
|
|
assert 'role="status"' in app_source
|
|
assert "IM 已连接 · ${props.statusText.value}" in app_source
|
|
assert ".message-row--call-status" in styles
|
|
assert ".call-status-event--connected" in styles
|
|
assert ".call-status-event--failed" in styles
|
|
|
|
|
|
def test_companion_local_recording_waits_for_real_audio_and_has_runtime_fallbacks() -> None:
|
|
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
assert "querySelectorAll<HTMLMediaElement>('video, audio')" in source
|
|
assert "stream.getAudioTracks()" in source
|
|
assert "navigator.mediaDevices.getUserMedia" in source
|
|
assert "await waitForCallAudioTracks(cloud, sessionId)" in source
|
|
assert "localRecordingAttachedSourceCount <= 0" in source
|
|
assert "localRecordingBytes < 1024" in source
|
|
assert "已阻止上传空文件" in source
|
|
|
|
|
|
def test_qt_close_waits_for_local_audio_finish_before_destroying_webengine() -> None:
|
|
"""A title-bar/desktop hangup must keep accepting bridge chunks until COS ack."""
|
|
|
|
source = (
|
|
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
|
|
).read_text(encoding="utf-8")
|
|
request_shutdown = source.split(
|
|
"def _request_companion_shutdown", 1
|
|
)[1].split("def _force_requested_shutdown", 1)[0]
|
|
begin_shutdown = source.split("def _begin_shutdown", 1)[1].split(
|
|
"def wait_for_lifecycles", 1
|
|
)[0]
|
|
close_event = source.split("def closeEvent", 1)[1].split(
|
|
"else:", 1
|
|
)[0]
|
|
|
|
assert "window.doctorConsultation?.close?.()" in request_shutdown
|
|
assert "self._shutdown_requested = True" in request_shutdown
|
|
assert "self._closing = True" not in request_shutdown
|
|
assert "event.ignore()" in close_event
|
|
assert "self._request_companion_shutdown" in close_event
|
|
assert "self._shutdown_timer.stop()" in begin_shutdown
|
|
assert "window.doctorConsultation?.close?.()" not in begin_shutdown
|
|
assert "if not self.open_im or self._shutdown_requested" in source
|
|
|
|
|
|
def test_local_audio_capture_keeps_and_persists_its_own_call_room_identity() -> None:
|
|
"""A later IM call cycle must not relabel an earlier recording."""
|
|
|
|
source = (
|
|
PROJECT_ROOT / "src" / "doctor_workstation" / "video" / "window.py"
|
|
).read_text(encoding="utf-8")
|
|
|
|
assert "lifecycle: OrderedCallLifecycle" in source
|
|
assert "lifecycle = capture.lifecycle" in source
|
|
assert "call_record_id=call_record_id" in source
|
|
assert 'room_id=lifecycle.current_room_id or ""' in source
|
|
assert "store.bind_identity(" in source
|
|
|
|
|
|
def test_companion_shows_incremental_subtitles_but_only_persists_final_segments() -> None:
|
|
main_source = (
|
|
PROJECT_ROOT / "video_companion" / "src" / "main.ts"
|
|
).read_text(encoding="utf-8")
|
|
component_source = (
|
|
PROJECT_ROOT / "video_companion" / "src" / "App.vue"
|
|
).read_text(encoding="utf-8")
|
|
handler = main_source.split("function handleTranscriberMessage", 1)[1].split(
|
|
"function subscribeTranscriber", 1
|
|
)[0]
|
|
|
|
assert "showLiveCaption(message)" in handler
|
|
assert "if (message.isCompleted !== true) return" in handler
|
|
assert handler.index("showLiveCaption(message)") < handler.index(
|
|
"if (message.isCompleted !== true) return"
|
|
)
|
|
assert 'aria-label="实时语音字幕"' in component_source
|
|
assert "liveCaptions.value" in component_source
|
|
assert "caption.speaker" in component_source
|
|
assert "caption.text" in component_source
|
|
|
|
|
|
def test_companion_screenshot_requires_doctor_confirmation_before_upload() -> None:
|
|
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
capture = source.split("async function captureScreenshot", 1)[1].split(
|
|
"function discardScreenshot", 1
|
|
)[0]
|
|
confirm = source.split("async function confirmScreenshot", 1)[1].split(
|
|
"watch(", 1
|
|
)[0]
|
|
|
|
assert "screenshotPreview.value = canvas.toDataURL" in capture
|
|
assert "onSaveScreenshot" not in capture
|
|
assert "await props.onSaveScreenshot(screenshotPreview.value)" in confirm
|
|
assert "确认画面后再保存到患者资料" in source
|
|
assert "确认并上传" in source
|
|
assert "取消" in source
|
|
|
|
|
|
def test_companion_loads_im_history_without_an_empty_first_page_cursor() -> None:
|
|
source = (PROJECT_ROOT / "video_companion" / "src" / "main.ts").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
load_messages = source.split("async function loadMessages", 1)[1].split(
|
|
"async function sendText", 1
|
|
)[0]
|
|
|
|
assert "nextReqMessageID: prepend ? nextReqMessageID : ''" not in load_messages
|
|
assert "nextReqMessageID?: string" in load_messages
|
|
assert (
|
|
"if (prepend && nextReqMessageID) request.nextReqMessageID = nextReqMessageID"
|
|
in load_messages
|
|
)
|
|
assert "chat.getMessageList(request)" in load_messages
|
|
|
|
|
|
def test_companion_preserves_im_scroll_position_for_live_and_older_messages() -> None:
|
|
source = (PROJECT_ROOT / "video_companion" / "src" / "App.vue").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
watcher = source.split("() => props.messages.value.length", 1)[1].split(
|
|
"watch(", 1
|
|
)[0]
|
|
load_earlier = source.split("async function loadEarlierMessages", 1)[1].split(
|
|
"async function runAction", 1
|
|
)[0]
|
|
|
|
assert "if (!stickToMessageBottom.value) return" in watcher
|
|
assert "stickToMessageBottom.value = false" in load_earlier
|
|
assert "container.scrollHeight - previousHeight" in load_earlier
|
|
assert '@scroll="handleMessageScroll"' in source
|
|
assert '@click="loadEarlierMessages"' in source
|
|
|
|
|
|
def test_normalizes_admin_ticket_aliases_to_companion_contract() -> None:
|
|
request = normalize_backend_ticket(
|
|
{
|
|
"sdkAppId": "1400123456",
|
|
"userId": " doctor_42 ",
|
|
"userSig": "short-lived-ticket",
|
|
"patientUserId": " patient_8 ",
|
|
"diagnosisId": 123,
|
|
"patientId": 8,
|
|
}
|
|
)
|
|
|
|
assert request == VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
patient_id=8,
|
|
)
|
|
assert request.to_web_config() == {
|
|
"SDKAppID": 1400123456,
|
|
"userID": "doctor_42",
|
|
"userSig": "short-lived-ticket",
|
|
"targetUserId": "patient_8",
|
|
"diagnosisId": 123,
|
|
}
|
|
|
|
|
|
def test_accepts_uppercase_aliases_and_nested_backend_envelope() -> None:
|
|
request = VideoCallRequest.from_backend_ticket(
|
|
{
|
|
"data": {
|
|
"SDKAppID": 1400123456,
|
|
"userID": "doctor_42",
|
|
"userSig": "ticket-value",
|
|
"targetUserId": "patient_8",
|
|
}
|
|
},
|
|
diagnosis_id="diagnosis-123",
|
|
patient_id=8,
|
|
backend_mode="embedded",
|
|
)
|
|
|
|
assert request.diagnosis_id == "diagnosis-123"
|
|
assert request.backend_mode is BackendMode.EMBEDDED
|
|
|
|
|
|
def test_accepts_repository_call_ticket_object_without_importing_core_models() -> None:
|
|
ticket = SimpleNamespace(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="ticket-value",
|
|
patient_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
raw={"sdkAppId": 1400123456},
|
|
)
|
|
|
|
request = normalize_backend_ticket(ticket, patient_id=8)
|
|
|
|
assert request.patient_id == 8
|
|
assert request.to_web_config()["targetUserId"] == "patient_8"
|
|
|
|
|
|
def test_secret_is_excluded_from_repr_and_safe_log_context() -> None:
|
|
request = normalize_backend_ticket(
|
|
{
|
|
"sdkAppId": 1400123456,
|
|
"userId": "doctor_42",
|
|
"userSig": "never-write-this-value",
|
|
"patientUserId": "patient_8",
|
|
},
|
|
diagnosis_id=123,
|
|
)
|
|
|
|
assert "never-write-this-value" not in repr(request)
|
|
assert "never-write-this-value" not in str(request.safe_log_context())
|
|
assert "user_sig" not in request.safe_log_context()
|
|
|
|
|
|
@pytest.mark.parametrize("forbidden_key", ["SDKSecretKey", "sdk_secret_key", "secretKey"])
|
|
def test_rejects_server_side_secret_material(forbidden_key: str) -> None:
|
|
with pytest.raises(VideoTicketError, match="forbidden server-side secret"):
|
|
normalize_backend_ticket(
|
|
{
|
|
"sdkAppId": 1400123456,
|
|
"userId": "doctor_42",
|
|
"userSig": "ticket-value",
|
|
"patientUserId": "patient_8",
|
|
"diagnosisId": 123,
|
|
forbidden_key: "must-never-reach-a-client",
|
|
}
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value"),
|
|
[
|
|
("sdkAppId", 0),
|
|
("userId", ""),
|
|
("userSig", ""),
|
|
("patientUserId", " "),
|
|
("diagnosisId", None),
|
|
],
|
|
)
|
|
def test_rejects_incomplete_or_invalid_ticket(field: str, value: object) -> None:
|
|
ticket: dict[str, object] = {
|
|
"sdkAppId": 1400123456,
|
|
"userId": "doctor_42",
|
|
"userSig": "ticket-value",
|
|
"patientUserId": "patient_8",
|
|
"diagnosisId": 123,
|
|
}
|
|
ticket[field] = value
|
|
|
|
with pytest.raises(VideoTicketError):
|
|
normalize_backend_ticket(ticket)
|
|
|
|
|
|
def test_rejects_conflicting_aliases_and_modes() -> None:
|
|
with pytest.raises(VideoTicketError, match="conflicting SDKAppID aliases"):
|
|
normalize_backend_ticket(
|
|
{
|
|
"SDKAppID": 1400123456,
|
|
"sdkAppId": 1400654321,
|
|
"userID": "doctor_42",
|
|
"userSig": "ticket-value",
|
|
"targetUserId": "patient_8",
|
|
"diagnosisId": 123,
|
|
}
|
|
)
|
|
|
|
with pytest.raises(VideoTicketError, match="backend mode"):
|
|
BackendMode.parse("native")
|
|
|
|
|
|
def test_launcher_rejects_browser_before_importing_window_or_writing_repository() -> None:
|
|
class Repository:
|
|
def start_call(self, **payload: object) -> None:
|
|
raise AssertionError(f"unexpected repository write: {payload}")
|
|
|
|
launcher = VideoCallLauncher(repository=Repository(), backend_mode="browser")
|
|
with pytest.raises(VideoTicketError, match="one-time handoff"):
|
|
launcher.prepare(
|
|
{
|
|
"sdkAppId": 1400123456,
|
|
"userId": "doctor_42",
|
|
"userSig": "ticket-value",
|
|
"patientUserId": "patient_8",
|
|
"diagnosisId": 123,
|
|
}
|
|
)
|
|
|
|
|
|
def test_call_lifecycle_is_fifo_daemon_and_never_blocks_caller() -> None:
|
|
events: list[tuple[object, ...]] = []
|
|
start_entered = threading.Event()
|
|
release_start = threading.Event()
|
|
|
|
class Repository:
|
|
def start_call(
|
|
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
|
) -> dict[str, int]:
|
|
start_entered.set()
|
|
assert release_start.wait(2)
|
|
events.append(("start", diagnosis_id, patient_id, call_type))
|
|
return {"call_record_id": 900}
|
|
|
|
def bind_call_room(
|
|
self, diagnosis_id: int, room_id: str, *, call_record_id: int
|
|
) -> None:
|
|
events.append(("bind", diagnosis_id, room_id, call_record_id))
|
|
|
|
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
|
|
events.append(("end", diagnosis_id, call_record_id))
|
|
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
patient_id=8,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
|
|
started_at = time.monotonic()
|
|
start_future = lifecycle.start()
|
|
bind_future = lifecycle.bind_room("456789")
|
|
duplicate_bind = lifecycle.bind_room("456789")
|
|
changed_bind = lifecycle.bind_room("another-room")
|
|
end_future = lifecycle.end("test")
|
|
elapsed = time.monotonic() - started_at
|
|
|
|
assert start_entered.wait(1)
|
|
assert elapsed < 0.2
|
|
assert lifecycle.worker_is_daemon is True
|
|
assert lifecycle.wait(0.01) is False
|
|
assert duplicate_bind is bind_future
|
|
assert changed_bind.result(timeout=0) is False
|
|
assert lifecycle.current_room_id == "456789"
|
|
|
|
release_start.set()
|
|
assert start_future.result(timeout=2) is True
|
|
assert bind_future.result(timeout=2) is True
|
|
assert end_future.result(timeout=2) is True
|
|
assert lifecycle.wait(1) is True
|
|
assert lifecycle.current_room_id == "456789"
|
|
|
|
assert events == [
|
|
("start", 123, 8, 2),
|
|
("bind", 123, "456789", 900),
|
|
("end", 123, 900),
|
|
]
|
|
|
|
|
|
def test_transcription_lifecycle_uses_start_record_id_and_remains_fifo() -> None:
|
|
events: list[tuple[object, ...]] = []
|
|
|
|
class Repository:
|
|
def start_call(
|
|
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
|
) -> dict[str, object]:
|
|
events.append(("start", diagnosis_id, patient_id, call_type))
|
|
return {"data": {"callRecordId": 901}}
|
|
|
|
def start_call_transcription(
|
|
self,
|
|
diagnosis_id: int,
|
|
call_record_id: int,
|
|
transcription_session_id: str,
|
|
*,
|
|
language: str,
|
|
) -> None:
|
|
events.append(
|
|
(
|
|
"transcription-start",
|
|
diagnosis_id,
|
|
call_record_id,
|
|
transcription_session_id,
|
|
language,
|
|
)
|
|
)
|
|
|
|
def upsert_call_transcript_segments(
|
|
self,
|
|
diagnosis_id: int,
|
|
call_record_id: int,
|
|
transcription_session_id: str,
|
|
segments: list[dict[str, object]],
|
|
) -> None:
|
|
events.append(
|
|
(
|
|
"segment",
|
|
diagnosis_id,
|
|
call_record_id,
|
|
transcription_session_id,
|
|
segments,
|
|
)
|
|
)
|
|
|
|
def finish_call_transcription(
|
|
self,
|
|
diagnosis_id: int,
|
|
call_record_id: int,
|
|
transcription_session_id: str,
|
|
expected_segment_count: int,
|
|
*,
|
|
status: str,
|
|
) -> None:
|
|
events.append(
|
|
(
|
|
"finish",
|
|
diagnosis_id,
|
|
call_record_id,
|
|
transcription_session_id,
|
|
expected_segment_count,
|
|
status,
|
|
)
|
|
)
|
|
|
|
def end_call(self, diagnosis_id: int) -> None:
|
|
events.append(("end", diagnosis_id))
|
|
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
patient_id=8,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
|
|
start = lifecycle.start()
|
|
transcription_start = lifecycle.start_transcription("session-1")
|
|
segment = lifecycle.save_transcript_segment(
|
|
{
|
|
"segment_id": "seg-1",
|
|
"speaker_user_id": "patient_8",
|
|
"speaker_role": "patient",
|
|
"timestamp": 1200,
|
|
"text": "patient words",
|
|
}
|
|
)
|
|
duplicate = lifecycle.save_transcript_segment(
|
|
{"segment_id": "seg-1", "text": "must not produce another write"}
|
|
)
|
|
finish = lifecycle.finish_transcription(status="completed")
|
|
end = lifecycle.end("test")
|
|
|
|
assert duplicate is segment
|
|
assert start.result(timeout=2) is True
|
|
assert transcription_start.result(timeout=2) is True
|
|
assert segment.result(timeout=2) is True
|
|
assert finish.result(timeout=2) is True
|
|
assert end.result(timeout=2) is True
|
|
assert lifecycle.wait(1) is True
|
|
assert lifecycle.call_record_id == 901
|
|
assert events == [
|
|
("start", 123, 8, 2),
|
|
("transcription-start", 123, 901, "session-1", "zh-CN"),
|
|
(
|
|
"segment",
|
|
123,
|
|
901,
|
|
"session-1",
|
|
[
|
|
{
|
|
"segment_id": "seg-1",
|
|
"transcription_session_id": "session-1",
|
|
"speaker_user_id": "patient_8",
|
|
"speaker_role": "patient",
|
|
"timestamp": 1200,
|
|
"text": "patient words",
|
|
}
|
|
],
|
|
),
|
|
("finish", 123, 901, "session-1", 1, "completed"),
|
|
("end", 123),
|
|
]
|
|
|
|
|
|
def test_end_auto_finishes_active_transcription_as_partial() -> None:
|
|
events: list[tuple[object, ...]] = []
|
|
|
|
class Repository:
|
|
def start_call(
|
|
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
|
) -> dict[str, int]:
|
|
events.append(("start", diagnosis_id, patient_id, call_type))
|
|
return {"call_record_id": 902}
|
|
|
|
def start_call_transcription(
|
|
self,
|
|
diagnosis_id: int,
|
|
call_record_id: int,
|
|
transcription_session_id: str,
|
|
*,
|
|
language: str,
|
|
) -> None:
|
|
events.append(
|
|
(
|
|
"transcription-start",
|
|
diagnosis_id,
|
|
call_record_id,
|
|
transcription_session_id,
|
|
language,
|
|
)
|
|
)
|
|
|
|
def finish_call_transcription(
|
|
self,
|
|
diagnosis_id: int,
|
|
call_record_id: int,
|
|
transcription_session_id: str,
|
|
expected_segment_count: int,
|
|
*,
|
|
status: str,
|
|
) -> None:
|
|
events.append(
|
|
(
|
|
"finish",
|
|
diagnosis_id,
|
|
call_record_id,
|
|
transcription_session_id,
|
|
expected_segment_count,
|
|
status,
|
|
)
|
|
)
|
|
|
|
def end_call(self, diagnosis_id: int) -> None:
|
|
events.append(("end", diagnosis_id))
|
|
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
patient_id=8,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
|
|
lifecycle.start()
|
|
lifecycle.start_transcription("session-auto-partial")
|
|
ended = lifecycle.end("window-closed")
|
|
|
|
assert ended.result(timeout=2) is True
|
|
assert lifecycle.wait(1) is True
|
|
assert events == [
|
|
("start", 123, 8, 2),
|
|
("transcription-start", 123, 902, "session-auto-partial", "zh-CN"),
|
|
("finish", 123, 902, "session-auto-partial", 0, "partial"),
|
|
("end", 123),
|
|
]
|
|
|
|
|
|
def test_failed_start_prevents_bind_and_end_writes() -> None:
|
|
events: list[str] = []
|
|
|
|
class Repository:
|
|
def start_call(self, diagnosis_id: int, *, call_type: int) -> None:
|
|
del diagnosis_id, call_type
|
|
events.append("start")
|
|
raise RuntimeError("backend unavailable")
|
|
|
|
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
|
|
del diagnosis_id, room_id
|
|
events.append("bind")
|
|
|
|
def end_call(self, diagnosis_id: int) -> None:
|
|
del diagnosis_id
|
|
events.append("end")
|
|
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
start_future = lifecycle.start()
|
|
bind_future = lifecycle.bind_room("456789")
|
|
end_future = lifecycle.end("test")
|
|
|
|
with pytest.raises(RuntimeError, match="backend unavailable"):
|
|
start_future.result(timeout=1)
|
|
assert bind_future.result(timeout=1) is False
|
|
assert end_future.result(timeout=1) is False
|
|
assert lifecycle.wait(1) is True
|
|
assert events == ["start"]
|
|
|
|
|
|
def test_explicit_cos_recording_failure_fails_room_binding_without_losing_call_identity() -> None:
|
|
class Repository:
|
|
def start_call(
|
|
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
|
) -> dict[str, int]:
|
|
del diagnosis_id, patient_id, call_type
|
|
return {"call_record_id": 904}
|
|
|
|
def bind_call_room(
|
|
self, diagnosis_id: int, room_id: str, *, call_record_id: int
|
|
) -> dict[str, object]:
|
|
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 904)
|
|
return {
|
|
"call_record_id": 904,
|
|
"cloud_recording": {
|
|
"started": False,
|
|
"message": "COS bucket is unavailable",
|
|
},
|
|
}
|
|
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
patient_id=8,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
|
|
with pytest.raises(RuntimeError, match="COS bucket is unavailable"):
|
|
lifecycle.bind_room("456789").result(timeout=2)
|
|
|
|
assert lifecycle.call_record_id == 904
|
|
assert lifecycle.bound_room_id is None
|
|
assert lifecycle.wait(1) is True
|
|
|
|
|
|
def test_failed_room_binding_releases_claim_and_can_retry_same_room() -> None:
|
|
bind_attempts = 0
|
|
|
|
class Repository:
|
|
def start_call(
|
|
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
|
) -> dict[str, int]:
|
|
del diagnosis_id, patient_id, call_type
|
|
return {"call_record_id": 906}
|
|
|
|
def bind_call_room(
|
|
self, diagnosis_id: int, room_id: str, *, call_record_id: int
|
|
) -> dict[str, object]:
|
|
nonlocal bind_attempts
|
|
assert (diagnosis_id, room_id, call_record_id) == (123, "456789", 906)
|
|
bind_attempts += 1
|
|
if bind_attempts == 1:
|
|
raise RuntimeError("temporary bind failure")
|
|
return {
|
|
"call_record_id": 906,
|
|
"cloud_recording": {"started": True},
|
|
}
|
|
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
patient_id=8,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
|
|
with pytest.raises(RuntimeError, match="temporary bind failure"):
|
|
lifecycle.bind_room("456789").result(timeout=2)
|
|
|
|
assert lifecycle.current_room_id is None
|
|
assert lifecycle.bind_room("456789").result(timeout=2) is True
|
|
assert lifecycle.bound_room_id == "456789"
|
|
assert lifecycle.current_room_id == "456789"
|
|
assert bind_attempts == 2
|
|
assert lifecycle.wait(1) is True
|
|
|
|
|
|
def test_local_audio_upload_uses_exact_started_record_and_precedes_end(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
events: list[tuple[object, ...]] = []
|
|
|
|
class Repository:
|
|
def start_call(
|
|
self, diagnosis_id: int, patient_id: int, *, call_type: int
|
|
) -> dict[str, int]:
|
|
events.append(("start", diagnosis_id, patient_id, call_type))
|
|
return {"call_record_id": 905}
|
|
|
|
def upload_call_recording(
|
|
self,
|
|
path: Path,
|
|
diagnosis_id: int,
|
|
*,
|
|
call_record_id: int,
|
|
mime_type: str,
|
|
) -> dict[str, object]:
|
|
events.append(
|
|
(
|
|
"local-audio",
|
|
path.read_bytes(),
|
|
diagnosis_id,
|
|
call_record_id,
|
|
mime_type,
|
|
)
|
|
)
|
|
return {
|
|
"completed": True,
|
|
"call_record_id": call_record_id,
|
|
"media_kind": "local_audio",
|
|
}
|
|
|
|
def end_call(self, diagnosis_id: int, *, call_record_id: int) -> None:
|
|
events.append(("end", diagnosis_id, call_record_id))
|
|
|
|
recording = tmp_path / "call-audio.webm"
|
|
recording.write_bytes(b"opus-webm-audio")
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
patient_id=8,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
|
|
lifecycle.start()
|
|
uploaded = lifecycle.save_local_audio_recording(
|
|
recording,
|
|
mime_type="audio/webm;codecs=opus",
|
|
)
|
|
ended = lifecycle.end("doctor-hangup")
|
|
|
|
assert uploaded.result(timeout=2) is True
|
|
assert ended.result(timeout=2) is True
|
|
assert lifecycle.wait(1) is True
|
|
assert events == [
|
|
("start", 123, 8, 2),
|
|
("local-audio", b"opus-webm-audio", 123, 905, "audio/webm;codecs=opus"),
|
|
("end", 123, 905),
|
|
]
|
|
|
|
|
|
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
|
|
events: list[tuple[object, ...]] = []
|
|
|
|
class Repository:
|
|
def start_call(self, diagnosis_id: int, *, call_type: int) -> dict[str, int]:
|
|
events.append(("start", diagnosis_id, call_type))
|
|
return {"call_record_id": 903}
|
|
|
|
def upload_material_bytes(
|
|
self,
|
|
content: bytes,
|
|
filename: str,
|
|
material_type: str,
|
|
cid: int = 0,
|
|
) -> str:
|
|
events.append(("upload", content, filename, material_type, cid))
|
|
return "/uploads/image/callshot-123.jpg"
|
|
|
|
def add_doctor_note(
|
|
self,
|
|
diagnosis_id: int,
|
|
content: str,
|
|
tongue_images: list[str],
|
|
) -> None:
|
|
events.append(("note", diagnosis_id, content, tongue_images))
|
|
|
|
def end_call(self, diagnosis_id: int) -> None:
|
|
events.append(("end", diagnosis_id))
|
|
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
|
|
lifecycle.start()
|
|
screenshot = lifecycle.save_screenshot(b"jpeg-frame", "callshot-123.jpg")
|
|
lifecycle.end("test")
|
|
|
|
assert screenshot.result(timeout=2) == "/uploads/image/callshot-123.jpg"
|
|
assert lifecycle.wait(1) is True
|
|
assert events == [
|
|
("start", 123, 2),
|
|
("upload", b"jpeg-frame", "callshot-123.jpg", "image", 0),
|
|
("note", 123, "", ["/uploads/image/callshot-123.jpg"]),
|
|
("end", 123),
|
|
]
|
|
|
|
|
|
def test_start_rejects_missing_record_id_without_using_ticket_fallback() -> None:
|
|
class Repository:
|
|
def start_call(self, diagnosis_id: int, *, call_type: int) -> dict[str, object]:
|
|
del diagnosis_id, call_type
|
|
return {}
|
|
|
|
request = VideoCallRequest(
|
|
sdk_app_id=1400123456,
|
|
user_id="doctor_42",
|
|
user_sig="short-lived-ticket",
|
|
target_user_id="patient_8",
|
|
diagnosis_id=123,
|
|
call_record_id=77,
|
|
)
|
|
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
|
|
|
with pytest.raises(ValueError, match="startCall response did not include"):
|
|
lifecycle.start().result(timeout=2)
|
|
|
|
assert lifecycle.started is False
|
|
|
|
|
|
def test_https_document_policy_is_exact_and_origin_scoped() -> None:
|
|
policy = TrustedDocumentPolicy.from_url(
|
|
"https://RTC.Example.com/doctor-call/index.html?tenant=a#boot",
|
|
is_local=False,
|
|
)
|
|
|
|
assert policy.allows_main_document(
|
|
"https://rtc.example.com:443/doctor-call/index.html?tenant=a#ready"
|
|
)
|
|
assert not policy.allows_main_document(
|
|
"https://rtc.example.com/doctor-call/index.html?tenant=b"
|
|
)
|
|
assert not policy.allows_main_document("https://rtc.example.com/other/index.html?tenant=a")
|
|
assert policy.allows_origin("https://rtc.example.com")
|
|
assert not policy.allows_origin("https://sub.rtc.example.com")
|
|
assert not policy.allows_origin("http://rtc.example.com")
|
|
|
|
with pytest.raises(TrustedDocumentError, match="HTTPS"):
|
|
TrustedDocumentPolicy.from_url("http://rtc.example.com/doctor-call", is_local=False)
|
|
|
|
|
|
def test_local_document_policy_rejects_sibling_files(tmp_path: Path) -> None:
|
|
index = tmp_path / "dist" / "index.html"
|
|
index.parent.mkdir()
|
|
index.touch()
|
|
sibling = index.with_name("other.html")
|
|
sibling.touch()
|
|
policy = TrustedDocumentPolicy.from_url(index.as_uri(), is_local=True)
|
|
|
|
assert policy.allows_main_document(index.as_uri())
|
|
assert not policy.allows_main_document(sibling.as_uri())
|
|
assert policy.allows_origin("file:///")
|