588 lines
21 KiB
Python
588 lines
21 KiB
Python
"""Behaviour tests for mutable demo data and tolerant model parsing."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from doctor_workstation.core.errors import (
|
|
ApiBusinessError,
|
|
ApiProtocolError,
|
|
AuthenticationExpiredError,
|
|
RepositoryNotFoundError,
|
|
)
|
|
from doctor_workstation.core.models import Appointment, PageResult
|
|
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
|
from doctor_workstation.services.repository import RemoteDoctorRepository
|
|
from doctor_workstation.services.token_store import TokenStore
|
|
|
|
|
|
@pytest.fixture
|
|
def repository() -> DemoDoctorRepository:
|
|
"""Return a fresh deterministic repository for each test."""
|
|
|
|
return DemoDoctorRepository(today=date(2026, 8, 10))
|
|
|
|
|
|
def test_demo_login_has_all_doctor_permissions(
|
|
repository: DemoDoctorRepository,
|
|
) -> None:
|
|
"""Documented credentials produce a typed, globally authorised session."""
|
|
|
|
with pytest.raises(ApiBusinessError):
|
|
repository.login("doctor", "wrong")
|
|
|
|
session = repository.login("doctor", "doctor123")
|
|
assert session.authenticated
|
|
assert session.user.name == "陈医生(演示)"
|
|
assert session.permissions.is_superuser
|
|
assert session.permissions.can("doctor.appointment", "complete")
|
|
assert session.permissions.can("tcm.prescriptionLibrary", "delete")
|
|
|
|
|
|
def test_complete_appointment_mutates_all_related_views(
|
|
repository: DemoDoctorRepository,
|
|
) -> None:
|
|
"""Completing reception is observable in queue, patient and diagnosis lists."""
|
|
|
|
repository.complete_appointment(101)
|
|
|
|
completed = repository.list_appointments(status=3).items
|
|
assert [item.id for item in completed] == [101, 104]
|
|
patient = repository.list_patients(keyword="林晓岚").items[0]
|
|
assert patient.appointment_status == 3
|
|
assert patient.status_filter == "completed"
|
|
consultation = repository.list_consultations(patient_name="林晓岚").items[0]
|
|
assert consultation.status == 3
|
|
assert repository.get_reception(101)["appointment"]["status"] == 3
|
|
|
|
|
|
def test_add_note_persists_in_reception_detail(
|
|
repository: DemoDoctorRepository,
|
|
) -> None:
|
|
"""Text and media added to a diagnosis remain available on later reads."""
|
|
|
|
before = len(repository.get_reception(101)["doctor_notes"])
|
|
created = repository.add_doctor_note(
|
|
501,
|
|
"午后睡意减轻",
|
|
tongue_images=["demo://tongue.png"],
|
|
report_files=["demo://report.pdf"],
|
|
)
|
|
|
|
after = repository.get_reception(101)["doctor_notes"]
|
|
assert len(after) == before + 1
|
|
assert after[-1] == created
|
|
assert after[-1]["tongue_images"] == ["demo://tongue.png"]
|
|
|
|
|
|
def test_demo_upload_material_returns_safe_uri_and_note_rejects_local_path(
|
|
repository: DemoDoctorRepository,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Demo mode exercises the same upload-before-note contract as production."""
|
|
|
|
source = tmp_path / "tongue.jpg"
|
|
source.write_bytes(b"demo-image")
|
|
uri = repository.upload_material(source, "image")
|
|
created = repository.add_doctor_note(501, tongue_images=[uri])
|
|
|
|
assert uri.startswith("/demo/uploads/image/")
|
|
assert str(tmp_path) not in uri
|
|
assert created["tongue_images"] == [uri]
|
|
with pytest.raises(ValueError, match="server uri/url"):
|
|
repository.add_doctor_note(501, tongue_images=[str(source)])
|
|
|
|
|
|
def test_prescription_template_crud_is_real_and_isolated(
|
|
repository: DemoDoctorRepository,
|
|
) -> None:
|
|
"""Create, update and delete change subsequent list/detail reads."""
|
|
|
|
original_count = repository.list_prescription_templates().total
|
|
created = repository.create_prescription_template(
|
|
name="益气演示方",
|
|
formula_type=1,
|
|
herbs=[{"name": "黄芪", "dosage": "20g"}],
|
|
is_public=False,
|
|
)
|
|
assert repository.list_prescription_templates().total == original_count + 1
|
|
|
|
updated = repository.update_prescription_template(
|
|
created.id,
|
|
{"name": "益气健脾演示方", "is_public": True},
|
|
)
|
|
assert updated.name == "益气健脾演示方"
|
|
assert updated.is_public
|
|
assert repository.get_prescription_template(created.id).name == updated.name
|
|
|
|
repository.delete_prescription_template(created.id)
|
|
assert repository.list_prescription_templates().total == original_count
|
|
with pytest.raises(RepositoryNotFoundError):
|
|
repository.get_prescription_template(created.id)
|
|
|
|
|
|
def test_demo_pagination_and_returned_copies(repository: DemoDoctorRepository) -> None:
|
|
"""Pagination metadata is stable and callers cannot mutate repository state."""
|
|
|
|
page = repository.list_appointments(page_no=1, page_size=1)
|
|
assert page.total == 5
|
|
assert page.pages == 5
|
|
page.items[0].patient_name = "外部改写"
|
|
assert repository.list_appointments(page_no=1, page_size=1).items[0].patient_name != (
|
|
"外部改写"
|
|
)
|
|
|
|
|
|
def test_demo_prescription_lookup_is_appointment_authoritative(
|
|
repository: DemoDoctorRepository,
|
|
) -> None:
|
|
"""Demo getByAppointment never substitutes another diagnosis-level record."""
|
|
|
|
first = repository.get_prescription_by_appointment(101)
|
|
second = repository.get_prescription_by_appointment(102)
|
|
missing = repository.get_prescription_by_appointment(103)
|
|
|
|
assert first is not None and first.id == 802 and first.appointment_id == 101
|
|
assert second is not None and second.id == 801 and second.appointment_id == 102
|
|
assert missing is None
|
|
assert first.case_record["appointment_id"] == 101
|
|
|
|
|
|
def test_demo_consultation_filters_and_dictionaries_cover_exposed_ui(
|
|
repository: DemoDoctorRepository,
|
|
) -> None:
|
|
"""Every visible consultation filter has deterministic offline semantics."""
|
|
|
|
for dictionary_type in (
|
|
"diagnosis_type",
|
|
"consultation_type",
|
|
"syndrome_type",
|
|
"appointment_channel_source",
|
|
"channels",
|
|
):
|
|
assert repository.get_dictionary(dictionary_type)
|
|
|
|
assert {row.id for row in repository.list_consultations(diagnosis_confirmed="1").items} == {
|
|
501,
|
|
503,
|
|
}
|
|
assert {row.id for row in repository.list_consultations(diagnosis_type="integrated").items} == {
|
|
502,
|
|
504,
|
|
}
|
|
assert [row.id for row in repository.list_consultations(syndrome_type="phlegm_damp").items] == [
|
|
502,
|
|
504,
|
|
]
|
|
assert [
|
|
row.id
|
|
for row in repository.list_consultations(latest_appointment_channel_source="clinic").items
|
|
] == [502]
|
|
assert [row.id for row in repository.list_consultations(pending_booking="1").items] == [504]
|
|
assert [row.id for row in repository.list_consultations(pending_assign="1").items] == [504]
|
|
assert [row.id for row in repository.list_consultations(completed_appointment="1").items] == [
|
|
501
|
|
]
|
|
sorted_rows = repository.list_consultations(sort_unserved_days="desc").items
|
|
assert [row.unserved_days for row in sorted_rows] == [14, 8, 1, 0]
|
|
|
|
|
|
def test_demo_roster_and_slots_fail_closed_to_known_doctor(
|
|
repository: DemoDoctorRepository,
|
|
) -> None:
|
|
"""Offline booking uses the same roster/slot boundary as production."""
|
|
|
|
rosters = repository.list_appointment_rosters(
|
|
doctor_id=1001,
|
|
start_date="2026-08-10",
|
|
end_date="2026-08-16",
|
|
)
|
|
slots = repository.get_available_appointment_slots(
|
|
doctor_id=1001,
|
|
appointment_date="2026-08-10",
|
|
)
|
|
|
|
assert rosters.total == 7
|
|
assert rosters.items[0]["date"] == "2026-08-10"
|
|
assert any(row["time"] == "09:00" and not row["available"] for row in slots["slots"])
|
|
assert (
|
|
repository.get_available_appointment_slots(
|
|
doctor_id=9999,
|
|
appointment_date="2026-08-10",
|
|
)["slots"]
|
|
== []
|
|
)
|
|
|
|
|
|
def test_demo_call_lifecycle_mutates_record(repository: DemoDoctorRepository) -> None:
|
|
"""Start, room binding and end operations share one mutable call record."""
|
|
|
|
ticket = repository.get_call_ticket(301, 501)
|
|
assert ticket.patient_user_id == "patient_301"
|
|
started = repository.start_call(501, 301)
|
|
assert started["status"] == "ringing"
|
|
bound = repository.bind_call_room(501, "room-501")
|
|
assert bound["room_id"] == "room-501"
|
|
ended = repository.end_call(501)
|
|
assert ended["status"] == "ended"
|
|
assert ended["room_id"] == "room-501"
|
|
|
|
|
|
def test_demo_transcript_upsert_and_finish_round_trip_in_call_records(
|
|
repository: DemoDoctorRepository,
|
|
) -> None:
|
|
"""Demo replay reads expose one finalized segment for a repeated segment ID."""
|
|
|
|
started = repository.start_call(501, 301)
|
|
call_record_id = started["id"]
|
|
repository.start_call_transcription(501, call_record_id, "session-1")
|
|
repository.upsert_call_transcript_segments(
|
|
501,
|
|
call_record_id,
|
|
"session-1",
|
|
[
|
|
{
|
|
"segment_id": "seg-1",
|
|
"speaker_user_id": "patient_301",
|
|
"speaker_role": "patient",
|
|
"timestamp": 1200,
|
|
"text": "draft words",
|
|
}
|
|
],
|
|
)
|
|
repository.upsert_call_transcript_segments(
|
|
501,
|
|
call_record_id,
|
|
"session-1",
|
|
[
|
|
{
|
|
"segment_id": "seg-1",
|
|
"speaker_user_id": "patient_301",
|
|
"speaker_role": "patient",
|
|
"timestamp": 1200,
|
|
"text": "final words",
|
|
}
|
|
],
|
|
)
|
|
repository.finish_call_transcription(
|
|
501,
|
|
call_record_id,
|
|
"session-1",
|
|
expected_segment_count=1,
|
|
status="completed",
|
|
)
|
|
repository.end_call(501)
|
|
|
|
record = next(
|
|
row for row in repository.list_call_records(501) if row["id"] == call_record_id
|
|
)
|
|
assert record["status"] == 2
|
|
assert record["transcription_status"] == "completed"
|
|
assert record["transcription_segment_count"] == 1
|
|
assert record["transcript_segments"] == [
|
|
{
|
|
"segment_id": "seg-1",
|
|
"speaker_role": "patient",
|
|
"speaker_user_id": "patient_301",
|
|
"timestamp": 1200,
|
|
"text": "final words",
|
|
}
|
|
]
|
|
assert "final words" in record["transcript_text"]
|
|
|
|
|
|
def test_demo_local_audio_is_preserved_separately_from_cloud_video_and_text(
|
|
repository: DemoDoctorRepository,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
started = repository.start_call(501, 301)
|
|
call_record_id = int(started["id"])
|
|
audio = tmp_path / "local-call.webm"
|
|
audio.write_bytes(b"webm-opus-audio")
|
|
|
|
result = repository.upload_call_recording(
|
|
audio,
|
|
501,
|
|
call_record_id=call_record_id,
|
|
mime_type="audio/webm;codecs=opus",
|
|
)
|
|
record = next(
|
|
row for row in repository.list_call_records(501) if row["id"] == call_record_id
|
|
)
|
|
|
|
assert result["media_kind"] == "local_audio"
|
|
assert result["call_record_id"] == call_record_id
|
|
assert record["local_audio_status"] == 2
|
|
assert record["local_audio_status_text"] == "已保存"
|
|
assert record["local_audio_urls_list"] == [result["file_url"]]
|
|
assert record["recording_urls_list"] == []
|
|
assert record["transcript_text"] == ""
|
|
|
|
|
|
def test_tolerant_page_parsing_accepts_aliases_and_bad_rows() -> None:
|
|
"""List parsing handles nullable fields, aliases and non-object rows safely."""
|
|
|
|
page = PageResult.from_payload(
|
|
{
|
|
"rows": [
|
|
{
|
|
"appointment_id": "9",
|
|
"patient_name": "测试患者",
|
|
"status": "waiting",
|
|
},
|
|
None,
|
|
],
|
|
"total": "12",
|
|
"current_page": "2",
|
|
"per_page": "5",
|
|
"meta": {"scope": "demo"},
|
|
},
|
|
Appointment.from_dict,
|
|
)
|
|
|
|
assert len(page.items) == 1
|
|
assert page.items[0].id == 9
|
|
assert page.items[0].status == "waiting"
|
|
assert page.total == 12
|
|
assert page.page_no == 2
|
|
assert page.extend == {"scope": "demo"}
|
|
|
|
|
|
class _StubApiClient:
|
|
"""No-network API client double that records repository endpoint use."""
|
|
|
|
def __init__(self) -> None:
|
|
self.base_url = "https://example.test/adminapi/"
|
|
self.token = ""
|
|
self.get_calls: list[tuple[str, dict[str, Any]]] = []
|
|
self.post_calls: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
def set_token(self, token: str) -> None:
|
|
"""Retain the synthetic login token."""
|
|
|
|
self.token = token
|
|
|
|
def clear_token(self) -> None:
|
|
"""Clear the synthetic login token."""
|
|
|
|
self.token = ""
|
|
|
|
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
|
"""Return a shape appropriate for the requested read endpoint."""
|
|
|
|
self.get_calls.append((endpoint, dict(params or {})))
|
|
if endpoint == "auth.admin/mySelf":
|
|
return {
|
|
"user": {"id": 1, "name": "远程医生", "role_ids": [1]},
|
|
"permissions": ["doctor.appointment/lists"],
|
|
"menu": [],
|
|
}
|
|
if endpoint.endswith("/detail"):
|
|
if endpoint.startswith("tcm.prescriptionLibrary"):
|
|
return {"id": 7, "prescription_name": "远程模板", "herbs": []}
|
|
if endpoint.startswith("tcm.prescription"):
|
|
return {"id": 8, "sn": "RX8", "patient_name": "远程患者"}
|
|
if endpoint == "doctor.appointment/reception":
|
|
return {"appointment": {"id": params["id"]}, "doctor_notes": []}
|
|
return {"lists": [], "count": 0, "extend": {}}
|
|
|
|
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
|
|
"""Return synthetic mutation data and record the exact JSON payload."""
|
|
|
|
body = dict(payload or {})
|
|
self.post_calls.append((endpoint, body))
|
|
if endpoint == "login/account":
|
|
return {"token": "remote-token", "is_paw": 1}
|
|
if endpoint == "tcm.prescriptionLibrary/add":
|
|
return {"id": 9}
|
|
if endpoint == "tcm.diagnosis/getCallSignature":
|
|
return {
|
|
"sdkAppId": 123,
|
|
"userId": "doctor_1",
|
|
"userSig": "short-lived",
|
|
"patientUserId": "patient_2",
|
|
}
|
|
if endpoint == "tcm.diagnosis/startCall":
|
|
return {"call_record_id": 901}
|
|
return {"ok": True}
|
|
|
|
|
|
def test_remote_repository_uses_all_confirmed_endpoints_without_network() -> None:
|
|
"""Every required remote operation maps to its audited admin endpoint."""
|
|
|
|
client = _StubApiClient()
|
|
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
|
|
|
|
session = repository.login(" doctor ", "secret")
|
|
assert session.token == "remote-token"
|
|
assert repository.get_current_user().name == "远程医生"
|
|
assert [path for path, _ in client.get_calls].count("auth.admin/mySelf") == 1
|
|
|
|
repository.list_appointments(keyword="林", page_no=2, page_size=15)
|
|
repository.get_reception(5)
|
|
repository.notify_assistant(5)
|
|
repository.add_doctor_note(6, "记录")
|
|
repository.complete_appointment(5)
|
|
repository.list_prescription_templates(keyword="方", formula_type="aux")
|
|
repository.get_prescription_template(7)
|
|
repository.create_prescription_template(
|
|
name="新方", formula_type="main", herbs=[{"name": "茯苓", "dosage": "10g"}]
|
|
)
|
|
repository.update_prescription_template(7, {"name": "改方"})
|
|
repository.delete_prescription_template(7)
|
|
repository.list_prescriptions(keyword="RX8", status=1)
|
|
repository.get_prescription(8)
|
|
repository.list_patients(status="completed")
|
|
repository.list_consultations(keyword="远程")
|
|
ticket = repository.get_call_ticket(2, 6)
|
|
repository.start_call(6, 2)
|
|
repository.end_call(6)
|
|
repository.bind_call_room(6, "room-6")
|
|
|
|
assert ticket.user_sig == "short-lived"
|
|
get_endpoints = {path for path, _ in client.get_calls}
|
|
assert {
|
|
"doctor.appointment/lists",
|
|
"doctor.appointment/reception",
|
|
"tcm.prescriptionLibrary/lists",
|
|
"tcm.prescriptionLibrary/detail",
|
|
"tcm.prescription/lists",
|
|
"tcm.prescription/detail",
|
|
"firstvisit.myPatient/lists",
|
|
"tcm.diagnosis/lists",
|
|
} <= get_endpoints
|
|
post_endpoints = {path for path, _ in client.post_calls}
|
|
assert {
|
|
"login/account",
|
|
"doctor.appointment/notifyAssistant",
|
|
"doctor.appointment/addDoctorNote",
|
|
"doctor.appointment/complete",
|
|
"tcm.prescriptionLibrary/add",
|
|
"tcm.prescriptionLibrary/edit",
|
|
"tcm.prescriptionLibrary/delete",
|
|
"tcm.diagnosis/getCallSignature",
|
|
"tcm.diagnosis/startCall",
|
|
"tcm.diagnosis/endCall",
|
|
"tcm.diagnosis/bindCallRoom",
|
|
} <= post_endpoints
|
|
template_list_call = next(
|
|
params
|
|
for endpoint, params in client.get_calls
|
|
if endpoint == "tcm.prescriptionLibrary/lists"
|
|
)
|
|
assert template_list_call["formula_type"] == "辅方"
|
|
prescription_list_call = next(
|
|
params for endpoint, params in client.get_calls if endpoint == "tcm.prescription/lists"
|
|
)
|
|
assert prescription_list_call == {
|
|
"sn": "RX8",
|
|
"audit_filter": "passed",
|
|
"page_no": 1,
|
|
"page_size": 20,
|
|
}
|
|
patient_call = next(
|
|
params for endpoint, params in client.get_calls if endpoint == "firstvisit.myPatient/lists"
|
|
)
|
|
assert patient_call["status_filter"] == "completed"
|
|
|
|
|
|
class _FailingProfileClient(_StubApiClient):
|
|
"""Client double whose post-login session validation always fails."""
|
|
|
|
def __init__(self, error: Exception) -> None:
|
|
super().__init__()
|
|
self.error = error
|
|
|
|
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
|
|
"""Raise the configured error only for the authoritative profile call."""
|
|
|
|
if endpoint == "auth.admin/mySelf":
|
|
self.get_calls.append((endpoint, dict(params or {})))
|
|
raise self.error
|
|
return super().get(endpoint, params)
|
|
|
|
|
|
def test_remote_login_persists_only_after_session_validation(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A failed ``mySelf`` call rolls back memory and never saves its token."""
|
|
|
|
client = _FailingProfileClient(ApiProtocolError("bad profile"))
|
|
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
|
|
store.save_token("older-token", account="older", scope=client.base_url)
|
|
saved: list[tuple[str, dict[str, Any]]] = []
|
|
original_save = store.save_token
|
|
|
|
def record_save(token: str, **metadata: Any) -> None:
|
|
saved.append((token, metadata))
|
|
original_save(token, **metadata)
|
|
|
|
monkeypatch.setattr(store, "save_token", record_save)
|
|
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
|
|
|
|
with pytest.raises(ApiProtocolError, match="bad profile"):
|
|
repository.login(
|
|
"doctor",
|
|
"secret",
|
|
remember_account=True,
|
|
)
|
|
|
|
assert saved == []
|
|
assert client.token == ""
|
|
assert store.load_token() is None
|
|
|
|
|
|
def test_remote_login_applies_remember_account_to_token_store(tmp_path: Path) -> None:
|
|
"""The checkbox choice controls account metadata while retaining the token."""
|
|
|
|
client = _StubApiClient()
|
|
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
|
|
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
|
|
|
|
repository.login("doctor", "secret", remember_account=True)
|
|
assert store.load_account() == "doctor"
|
|
assert store.load_token(scope=client.base_url) == "remote-token"
|
|
|
|
repository.logout()
|
|
repository.login("doctor", "secret", remember_account=False)
|
|
assert store.load_account() is None
|
|
assert store.load_token(scope=client.base_url) == "remote-token"
|
|
|
|
|
|
def test_expired_persisted_token_is_removed_during_restore(tmp_path: Path) -> None:
|
|
"""An invalid startup token cannot trigger the same failed restore next run."""
|
|
|
|
client = _FailingProfileClient(AuthenticationExpiredError("expired", code=-1))
|
|
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
|
|
store.save_token("expired-token", account="doctor", scope=client.base_url)
|
|
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
|
|
|
|
with pytest.raises(AuthenticationExpiredError):
|
|
repository.restore_session()
|
|
|
|
assert client.token == ""
|
|
assert store.load_token() is None
|
|
assert store.load_account() == "doctor"
|
|
|
|
|
|
def test_restore_never_sends_token_to_a_different_api_scope(tmp_path: Path) -> None:
|
|
"""Changing the configured server invalidates automatic token reuse."""
|
|
|
|
client = _StubApiClient()
|
|
store = TokenStore(tmp_path / "credentials.json", keyring_backend=None)
|
|
store.save_token(
|
|
"other-server-token",
|
|
account="doctor",
|
|
scope="https://other.test/adminapi/",
|
|
)
|
|
repository = RemoteDoctorRepository(client, store) # type: ignore[arg-type]
|
|
|
|
assert repository.restore_session() is None
|
|
assert client.token == ""
|
|
assert client.get_calls == []
|