first commit
This commit is contained in:
@@ -0,0 +1,559 @@
|
||||
"""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_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 == []
|
||||
Reference in New Issue
Block a user