This commit is contained in:
Your Name
2026-08-10 17:29:05 +08:00
parent 2199887c07
commit 9add23e019
129 changed files with 34157 additions and 59 deletions
+272
View File
@@ -0,0 +1,272 @@
"""Contract tests for the UI-independent API client and token store."""
from __future__ import annotations
import json
from pathlib import Path
import httpx
import pytest
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiError,
ApiProtocolError,
ApiTimeoutError,
AuthenticationExpiredError,
OpenPageRequiredError,
WorkWechatBindingRequiredError,
)
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.token_store import TokenStore
def test_get_normalises_adminapi_and_sends_contract_headers() -> None:
"""The site base and already-prefixed base resolve to the same API URL."""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"code": 1, "data": {"ok": True}})
with ApiClient(
"https://example.test/root/",
token="secret-token",
transport=httpx.MockTransport(handler),
) as client:
assert client.get("/doctor.appointment/lists", {"page_no": 2}) == {"ok": True}
request = requests[0]
assert str(request.url) == (
"https://example.test/root/adminapi/doctor.appointment/lists?page_no=2"
)
assert request.headers["token"] == "secret-token"
assert request.headers["version"] == "1.9.4"
assert ApiClient.normalise_base_url("https://example.test/adminapi") == (
"https://example.test/adminapi/"
)
def test_post_uses_json_and_never_retries_timeout() -> None:
"""Writes use JSON and a timeout never causes an automatic duplicate POST."""
attempts = 0
bodies: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
bodies.append(json.loads(request.content))
raise httpx.ReadTimeout("slow write", request=request)
client = ApiClient(
"https://example.test",
max_retries=5,
transport=httpx.MockTransport(handler),
)
with pytest.raises(ApiTimeoutError) as caught:
client.post("doctor.appointment/complete", {"id": 42})
client.close()
assert attempts == 1
assert bodies == [{"id": 42}]
assert caught.value.data["attempts"] == 1
def test_multipart_post_lets_httpx_set_boundary_and_sends_form_fields(
tmp_path: Path,
) -> None:
"""Uploads use real multipart encoding without the JSON content type."""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200,
json={"code": 1, "data": {"uri": "/uploads/demo.jpg"}},
)
source = tmp_path / "demo.jpg"
source.write_bytes(b"jpeg-demo-bytes")
with (
ApiClient(
"https://example.test",
transport=httpx.MockTransport(handler),
) as client,
source.open("rb") as stream,
):
result = client.post_multipart(
"upload/image",
files={"file": (source.name, stream, "image/jpeg")},
data={"cid": "0"},
)
assert result == {"uri": "/uploads/demo.jpg"}
request = requests[0]
content_type = request.headers["content-type"]
assert content_type.startswith("multipart/form-data; boundary=")
assert "application/json" not in content_type
assert b'name="file"; filename="demo.jpg"' in request.content
assert b'name="cid"' in request.content
assert b"jpeg-demo-bytes" in request.content
def test_get_retries_only_timeouts_then_returns_data() -> None:
"""A GET may recover from a bounded number of timeout failures."""
attempts = 0
def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts < 3:
raise httpx.ReadTimeout("temporary", request=request)
return httpx.Response(200, json={"code": "1", "data": ["ready"]})
with ApiClient(
"https://example.test/adminapi/",
max_retries=2,
transport=httpx.MockTransport(handler),
) as client:
assert client.get("health") == ["ready"]
assert attempts == 3
@pytest.mark.parametrize(
("code", "exception_type"),
[
(0, ApiBusinessError),
(-1, AuthenticationExpiredError),
(10, WorkWechatBindingRequiredError),
],
)
def test_envelope_error_codes_are_structured(code: int, exception_type: type[Exception]) -> None:
"""Known control-flow codes become typed exceptions with response data."""
transport = httpx.MockTransport(
lambda request: httpx.Response(
200,
headers={"x-request-id": "req-123"},
json={"code": code, "msg": "action needed", "data": {"reason": "demo"}},
)
)
with (
ApiClient("https://example.test", transport=transport) as client,
pytest.raises(exception_type) as caught,
):
client.get("auth.admin/mySelf")
error = caught.value
assert isinstance(error, ApiError)
assert error.code == code
assert error.data == {"reason": "demo"}
assert error.request_id == "req-123"
def test_open_page_signal_does_not_open_a_browser() -> None:
"""Code 2 is surfaced to the UI as data, not executed by the service layer."""
transport = httpx.MockTransport(
lambda request: httpx.Response(
200,
json={"code": 2, "data": {"url": "https://example.test/continue"}},
)
)
with (
ApiClient("https://example.test", transport=transport) as client,
pytest.raises(OpenPageRequiredError) as caught,
):
client.get("continue")
assert caught.value.url == "https://example.test/continue"
def test_invalid_envelope_raises_protocol_error() -> None:
"""Successful HTTP is not mistaken for API success without a valid envelope."""
transport = httpx.MockTransport(
lambda request: httpx.Response(200, json={"data": "missing code"})
)
with (
ApiClient("https://example.test", transport=transport) as client,
pytest.raises(ApiProtocolError),
):
client.get("broken")
def test_token_store_file_fallback_never_persists_password(tmp_path: Path) -> None:
"""The fallback contains only an access token and an optional account name."""
path = tmp_path / "credentials.json"
store = TokenStore(path, keyring_backend=None)
store.save_token("token-value", account="doctor")
assert store.load_token() == "token-value"
assert store.load_account() == "doctor"
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload == {"token": "token-value", "account": "doctor"}
assert "password" not in path.read_text(encoding="utf-8").lower()
store.clear_token()
assert store.load_token() is None
assert store.load_account() == "doctor"
class _MemoryKeyring:
"""Minimal deterministic keyring double."""
def __init__(self) -> None:
self.values: dict[tuple[str, str], str] = {}
def get_password(self, service: str, username: str) -> str | None:
"""Return an in-memory secret."""
return self.values.get((service, username))
def set_password(self, service: str, username: str, password: str) -> None:
"""Store an in-memory secret."""
self.values[(service, username)] = password
def delete_password(self, service: str, username: str) -> None:
"""Delete an in-memory secret."""
self.values.pop((service, username), None)
def test_token_store_prefers_available_keyring(tmp_path: Path) -> None:
"""A working keyring keeps the token out of the fallback JSON file."""
backend = _MemoryKeyring()
path = tmp_path / "credentials.json"
store = TokenStore(path, keyring_backend=backend)
store.save_token("keyring-token", account="doctor")
assert store.uses_keyring
assert store.load_token() == "keyring-token"
assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"}
def test_token_store_scopes_automatic_restore_and_forgets_account(tmp_path: Path) -> None:
"""Automatic restore never returns a token issued for another API base."""
path = tmp_path / "credentials.json"
store = TokenStore(path, keyring_backend=None)
api_scope = "https://example.test/adminapi/"
store.save_token(
"scoped-token",
account="doctor",
scope=api_scope,
)
assert store.load_token(scope="https://example.test/adminapi") == "scoped-token"
assert store.load_token(scope="https://other.test/adminapi/") is None
assert store.load_token() == "scoped-token"
assert json.loads(path.read_text(encoding="utf-8")) == {
"token": "scoped-token",
"account": "doctor",
"scope": "https://example.test/adminapi",
}
store.save_token("next-token", account="", scope=api_scope)
assert store.load_account() is None
assert "account" not in json.loads(path.read_text(encoding="utf-8"))
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from pathlib import Path
import pytest
from doctor_workstation.config import AppConfig, normalize_api_base_url
@pytest.mark.parametrize(
("raw", "expected"),
[
("https://api.example.com", "https://api.example.com/adminapi"),
("https://api.example.com/", "https://api.example.com/adminapi"),
("https://api.example.com/adminapi", "https://api.example.com/adminapi"),
("http://127.0.0.1:8080/gateway", "http://127.0.0.1:8080/gateway/adminapi"),
("", ""),
],
)
def test_normalize_api_base_url(raw: str, expected: str) -> None:
assert normalize_api_base_url(raw) == expected
@pytest.mark.parametrize(
"raw",
["api.example.com", "ftp://api.example.com", "https://u:p@example.com", "https://x.test?a=1"],
)
def test_normalize_api_base_url_rejects_unsafe_values(raw: str) -> None:
with pytest.raises(ValueError):
normalize_api_base_url(raw)
def test_config_update_validates_video_mode() -> None:
with pytest.raises(ValueError):
AppConfig().with_updates(video_mode="unknown")
def test_runtime_directories_can_be_isolated_without_replacing_user_home(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_dir = tmp_path / "config"
log_dir = tmp_path / "logs"
monkeypatch.setenv("DOCTOR_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("DOCTOR_LOG_DIR", str(log_dir))
config = AppConfig()
assert config.config_dir == config_dir
assert config.log_dir == log_dir
+405
View File
@@ -0,0 +1,405 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtWidgets import QApplication, QDialog
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.pages import consultations as consultations_module
from doctor_workstation.ui.pages.consultations import (
ConsultationsPage,
_video_payload,
appointment_rows,
is_diagnosis_confirmed,
is_video_available,
prescription_action_label,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(consultations_module, "run_async", run_immediately)
monkeypatch.setattr(diagnosis_module, "run_async", run_immediately)
def _row(**changes: Any) -> dict[str, Any]:
row = {
"id": 501,
"diagnosis_id": 501,
"patient_id": 301,
"patient_name": "林晓岚",
"gender": 2,
"age": 36,
"status": 4,
"has_appointment": 1,
"appointment_id": 101,
"appointment_status": 1,
"appointments": [
{
"id": 101,
"status": 1,
"doctor_name": "陈医生",
"appointment_date": "2026-08-10",
"time_text": "09:00",
}
],
"DiagnosisViewRecord": [{"is_confirmed": 1}],
"has_prescription": 0,
}
row.update(changes)
return row
def test_video_condition_never_uses_diagnosis_status_or_missed_status() -> None:
assert is_video_available(_row(status=4, appointment_status=1))
assert not is_video_available(_row(status=1, appointment_status=4))
assert not is_video_available(_row(status=1, appointment_status=1, has_appointment=0))
payload = _video_payload(_row(id=777, diagnosis_id=777, appointment_id=222))
assert payload["appointment_id"] == 222
assert payload["diagnosis_id"] == 777
assert payload["patient_id"] == 301
def test_nested_appointments_confirmation_and_prescription_labels() -> None:
row = _row(
appointment_id=None,
appointment_status=None,
DiagnosisViewRecord=[{"is_confirmed": 0}, {"is_confirmed": "1"}],
)
assert appointment_rows(row)[0]["id"] == 101
assert is_diagnosis_confirmed(row)
assert (
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 0})
== "查看处方"
)
assert (
prescription_action_label({"prescription_audit_status": 1, "prescription_void_status": 1})
== "开方"
)
def test_default_query_matches_admin_today_and_page_size_contract(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_consultations(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
page.refresh(silent=True)
assert len(calls) == 1
query = calls[0]
assert query["page_no"] == 1
assert query["page_size"] == 15
assert query["appointment_date"] == QDate.currentDate().toString("yyyy-MM-dd")
assert "status" not in query
assert query["has_appointment"] == ""
assert query["diagnosis_confirmed"] == ""
assert {
"diagnosis_type",
"syndrome_type",
"assistant_id",
"latest_appointment_start_date",
"latest_appointment_end_date",
"latest_appointment_channel_source",
"latest_assign_start_date",
"latest_assign_end_date",
"sort_unserved_days",
}.issubset(query)
assert "consultation_type" not in query
page.close()
application.processEvents()
def test_double_click_opens_readonly_and_never_emits_video(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = ConsultationsPage(
SimpleNamespace(),
permissions=PermissionSet(["tcm.diagnosis/readonlyDetail", "tcm.diagnosis/videoQr"]),
)
page.table.set_rows([_row()])
page.table.selectRow(0)
opened: list[tuple[int, bool]] = []
videos: list[dict[str, Any]] = []
monkeypatch.setattr(
page._diagnosis_dialog,
"open_for",
lambda diagnosis_id, *, editable=False, seed=None: opened.append((diagnosis_id, editable)),
)
page.video_requested.connect(videos.append)
page.table.itemDoubleClicked.emit(page.table.item(0, 0))
application.processEvents()
assert opened == [(501, False)]
assert videos == []
page.close()
def test_action_visibility_requires_exact_canonical_permissions(
application: QApplication,
) -> None:
aliases = PermissionSet(
[
"tcm.diagnosis.readonlyDetail",
"tcm.diagnosis.edit",
"tcm.diagnosis.add",
"tcm.diagnosis.delete",
]
)
page = ConsultationsPage(SimpleNamespace(), permissions=aliases)
assert not page.view_button.isVisible()
assert not page.edit_button.isVisible()
assert not page.add_button.isVisible()
assert not page.delete_button.isVisible()
page.close()
exact = PermissionSet(
[
"tcm.diagnosis/readonlyDetail",
"tcm.diagnosis/edit",
"tcm.diagnosis/add",
"tcm.diagnosis/delete",
]
)
page = ConsultationsPage(SimpleNamespace(), permissions=exact)
assert not page.view_button.isHidden()
assert not page.edit_button.isHidden()
assert not page.add_button.isHidden()
assert not page.delete_button.isHidden()
page.close()
application.processEvents()
def test_refresh_generation_ignores_late_results(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(consultations_module, "run_async", queue_async)
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
page.refresh(silent=True)
page.refresh(silent=True)
callbacks[1]["on_success"]({"lists": [_row(id=902, diagnosis_id=902)], "count": 1})
callbacks[0]["on_success"]({"lists": [_row(id=901, diagnosis_id=901)], "count": 1})
application.processEvents()
assert page.table.rowCount() == 1
assert page.table.item(0, 0).text().startswith("902")
page.close()
def test_current_appointment_is_the_only_prescription_authority(
application: QApplication,
) -> None:
calls: list[tuple[str, int]] = []
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
calls.append(("appointment", appointment_id))
return None
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
assert page._load_context_prescription(_row(appointment_id=202)) is None
assert calls == [("appointment", 202)]
page.close()
application.processEvents()
def test_empty_appointment_wrapper_is_treated_as_a_new_prescription(
application: QApplication,
) -> None:
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 202
return {"data": {}}
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
assert page._load_context_prescription(_row(appointment_id=202)) is None
page.close()
application.processEvents()
def test_prescription_query_error_is_fail_closed_without_diagnosis_fallback(
application: QApplication,
) -> None:
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
raise RuntimeError(f"appointment {appointment_id} unavailable")
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
with pytest.raises(RuntimeError, match="appointment 202 unavailable"):
page._load_context_prescription(_row(appointment_id=202))
page.close()
application.processEvents()
def test_new_prescription_uses_authoritative_case_snapshot_and_exact_ids(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, int]] = []
created: list[dict[str, Any]] = []
dialog_seeds: list[dict[str, Any]] = []
case_record = {
"diagnosis": {"id": 501, "patient_name": "林晓岚", "chief_complaint": "咳嗽"},
"patient": {"id": 301, "gender": 2, "age": 36},
}
class Repository:
def get_prescription_by_appointment(self, appointment_id: int) -> None:
calls.append(("appointment", appointment_id))
return None
def list_prescriptions_by_diagnosis(self, diagnosis_id: int) -> list[dict[str, Any]]:
raise AssertionError(f"diagnosis fallback is forbidden: {diagnosis_id}")
def get_diagnosis_detail(self, diagnosis_id: int, **_kwargs: Any) -> dict[str, Any]:
calls.append(("diagnosis_detail", diagnosis_id))
return case_record
def create_prescription(self, prescription: Any) -> dict[str, Any]:
created.append(dict(prescription))
return {"id": 901}
def list_consultations(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
class AcceptedEditor:
def __init__(
self,
_repository: Any,
seed: dict[str, Any],
**_kwargs: Any,
) -> None:
dialog_seeds.append(seed)
def exec(self) -> QDialog.DialogCode:
return QDialog.DialogCode.Accepted
def payload(self) -> dict[str, Any]:
return {"formula_name": "止咳方", "medicines": []}
monkeypatch.setattr(consultations_module, "PrescriptionEditorDialog", AcceptedEditor)
page = ConsultationsPage(Repository(), permissions=PermissionSet(["*"]))
page._begin_prescription_load(_row(appointment_id=202), mode="open")
assert calls[:2] == [("appointment", 202), ("diagnosis_detail", 501)]
assert len(created) == 1
assert created[0]["diagnosis_id"] == 501
assert created[0]["appointment_id"] == 202
assert created[0]["case_record"] == case_record
assert created[0]["case_record"] is not case_record
assert dialog_seeds[0]["case_record"] == case_record
assert dialog_seeds[0]["case_record"] is not case_record
page.close()
application.processEvents()
def test_switching_rows_invalidates_prescription_worker_and_clears_busy(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(consultations_module, "run_async", queue_async)
page.table.set_rows(
[
_row(id=501, diagnosis_id=501, appointment_id=101),
_row(id=502, diagnosis_id=502, appointment_id=202),
]
)
page.table.selectRow(0)
page._begin_prescription_load(page.table.current_data(), mode="open")
assert page._prescription_busy
assert not page.prescription_button.isEnabled()
page.table.selectRow(1)
application.processEvents()
assert not page._prescription_busy
assert page.prescription_button.isEnabled()
callbacks[0]["on_success"]({"id": 88, "appointment_id": 101})
callbacks[0]["on_finished"]()
assert page.table.current_data()["appointment_id"] == 202
assert not page._prescription_busy
page.close()
application.processEvents()
def test_native_call_does_not_reuse_video_qr_permission(
application: QApplication,
) -> None:
page = ConsultationsPage(SimpleNamespace(), permissions=PermissionSet([]))
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page.table.set_rows([_row()])
page.table.selectRow(0)
application.processEvents()
assert not page.video_button.isHidden()
assert page.video_button.isEnabled()
page._request_video()
assert emitted == [_video_payload(_row())]
page.close()
application.processEvents()
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
import pytest
from doctor_workstation import __main__ as entrypoint
def test_smoke_entrypoint_returns_nonzero_instead_of_opening_crash_dialog(
monkeypatch: Any,
capsys: Any,
) -> None:
def fail_startup() -> int:
raise RuntimeError("startup failed")
monkeypatch.setattr(entrypoint, "main", fail_startup)
monkeypatch.setenv("DOCTOR_SMOKE_TEST", "1")
assert entrypoint._run() == 1
assert "RuntimeError: startup failed" in capsys.readouterr().err
def test_normal_entrypoint_preserves_startup_exception(monkeypatch: Any) -> None:
def fail_startup() -> int:
raise RuntimeError("startup failed")
monkeypatch.setattr(entrypoint, "main", fail_startup)
monkeypatch.delenv("DOCTOR_SMOKE_TEST", raising=False)
monkeypatch.setattr(entrypoint.sys, "argv", ["doctor-workstation"])
with pytest.raises(RuntimeError, match="startup failed"):
entrypoint._run()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import logging
from doctor_workstation.logging_setup import SecretRedactionFilter
def test_redaction_filter_hides_credentials() -> None:
record = logging.LogRecord(
name="test",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg="token: abc123 userSig='secret-value' password=hunter2",
args=(),
exc_info=None,
)
assert SecretRedactionFilter().filter(record)
rendered = record.getMessage()
assert "abc123" not in rendered
assert "secret-value" not in rendered
assert "hunter2" not in rendered
assert rendered.count("<redacted>") == 3
+494
View File
@@ -0,0 +1,494 @@
"""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]
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 == 3
assert page.pages == 3
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_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",
}
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 == []
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def read(relative_path: str) -> str:
return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
for name in (
"Run_DoctorWorkstation.bat",
"Build_DoctorWorkstation.bat",
"一键运行_医生工作站.bat",
"一键打包_医生工作站.bat",
):
assert (PROJECT_ROOT / name).is_file()
run_script = read("scripts/run_windows.ps1")
package_script = read("scripts/package_windows.ps1")
release_launcher = read("packaging/windows/start_release.bat")
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
assert "& $Uv sync --frozen" in run_script
assert "& $Uv sync --frozen --extra build" in package_script
assert "& $Npm ci --prefix" in package_script
assert "build_windows.ps1" in package_script
assert "DoctorWorkstation-Windows-x64-$ProjectVersion.zip" in package_script
assert "Get-FileHash" in package_script
assert "Start_DoctorWorkstation.bat" in package_script
assert "DoctorWorkstation\\DoctorWorkstation.exe" in release_launcher
assert "explorer.exe" in read("Build_DoctorWorkstation.bat")
def test_macos_one_click_entrypoints_and_release_pipeline() -> None:
for name in (
"run_macos.command",
"package_macos.command",
"一键运行.command",
"一键打包.command",
):
assert (PROJECT_ROOT / name).is_file()
run_script = read("scripts/run_macos.sh")
package_script = read("scripts/package_macos.sh")
assert run_script.index('/usr/bin/open "$artifact"') < run_script.index("macos_ensure_uv")
assert "sync --locked" in run_script
assert "sync --locked --extra build" in package_script
assert 'ci --prefix "$project_root/video_companion"' in package_script
assert "/usr/bin/ditto -c -k --sequesterRsrc --keepParent" in package_script
assert "/usr/bin/shasum -a 256" in package_script
assert "DoctorWorkstation-macOS-$release_arch-$project_version.zip" in package_script
+556
View File
@@ -0,0 +1,556 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import QDate
from PySide6.QtWidgets import QApplication, QInputDialog
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
from doctor_workstation.ui.pages import patients as patients_module
from doctor_workstation.ui.pages.patients import (
PatientListWorkspace,
PatientOrdersWorkspace,
PatientProgressWorkspace,
PatientsPage,
_AppointmentDialog,
_PaymentDialog,
_RefundDialog,
)
from doctor_workstation.ui.shell import NAVIGATION, ShellWindow, _resolve_navigation
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(patients_module, "run_async", run_immediately)
monkeypatch.setattr(diagnosis_module, "run_async", run_immediately)
def test_shell_resolves_dynamic_menu_order_visibility_and_canonical_permissions() -> None:
permissions = PermissionSet(
[
"firstvisit.myPatient/lists",
"tcm.diagnosis/lists",
"tcm.prescription/lists",
"doctor.appointment/lists",
]
)
menu = [
{
"name": "隐藏接诊",
"perms": "doctor.appointment/lists",
"sort": 999,
"is_show": 0,
},
{
"name": "诊疗中心",
"sort": 20,
"children": [
{
"name": "患者工作区",
"component": "first_visit/my_patients/index",
"sort": 80,
},
{
"name": "问诊工作区",
"perms": "tcm.diagnosis/lists",
"sort": 60,
},
],
},
{
"name": "停用处方",
"perms": "tcm.prescription/lists",
"sort": 30,
"is_disable": 1,
},
]
resolved = _resolve_navigation(menu, permissions, demo_mode=False)
assert [(item.key, title) for item, title in resolved] == [
("patients", "患者工作区"),
("consultations", "问诊工作区"),
]
assert _resolve_navigation([], permissions, demo_mode=False) == []
assert [item.key for item, _title in _resolve_navigation([], permissions, demo_mode=True)] == [
"reception",
"prescriptions",
"patients",
"consultations",
]
assert (
_resolve_navigation(
[{"perms": "firstvisit.myPatient/lists"}],
PermissionSet(["firstvisit.myPatient.lists"]),
demo_mode=False,
)
== []
)
def test_patient_page_runs_all_three_demo_workspaces_and_progress_timer(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
page.resize(808, 560)
page.show()
application.processEvents()
assert [page.tabs.tabText(index) for index in range(page.tabs.count())] == [
"患者列表",
"订单管理",
"面诊进度",
]
assert page.patient_workspace.table.rowCount() > 0
assert page.patient_workspace.scope_label.text() != ""
page.tabs.setCurrentIndex(1)
application.processEvents()
assert page.order_workspace.table.rowCount() > 0
assert page.order_workspace.metrics["orders"].text() == "1"
assert page.order_workspace.metrics["amount"].text() == "¥368.00"
page.tabs.setCurrentIndex(2)
application.processEvents()
assert page.progress_workspace.timer.isActive()
assert page.progress_workspace.schedule_table.rowCount() == 7
page.tabs.setCurrentIndex(0)
assert not page.progress_workspace.timer.isActive()
page.close()
application.processEvents()
def test_patient_refresh_generation_ignores_late_results(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(patients_module, "run_async", queue_async)
workspace = PatientListWorkspace(SimpleNamespace(), PermissionSet(["*"]))
workspace.refresh()
workspace.refresh()
newer = {
"lists": [{"id": 2, "diagnosis_id": 2, "patient_name": "新结果"}],
"count": 1,
}
stale = {
"lists": [{"id": 1, "diagnosis_id": 1, "patient_name": "旧结果"}],
"count": 1,
}
callbacks[1]["on_success"](newer)
callbacks[0]["on_success"](stale)
application.processEvents()
assert workspace.table.rowCount() == 1
assert workspace.table.item(0, 0).text().startswith("新结果")
workspace.close()
def test_workspace_queries_use_frozen_page_no_contract(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[tuple[str, dict[str, Any]]] = []
class Repository:
def list_patients(self, **kwargs: Any) -> dict[str, Any]:
calls.append(("patients", kwargs))
return {"lists": [], "count": 0}
def patient_orders(self, **kwargs: Any) -> dict[str, Any]:
calls.append(("orders", kwargs))
return {"lists": [], "count": 0}
def patient_progress(self, **kwargs: Any) -> dict[str, Any]:
calls.append(("progress", kwargs))
return {"lists": [], "count": 0}
repository = Repository()
patient = PatientListWorkspace(repository, PermissionSet(["*"]))
orders = PatientOrdersWorkspace(repository, PermissionSet(["*"]))
progress = PatientProgressWorkspace(repository)
patient.refresh()
orders.refresh()
progress.refresh()
assert [name for name, _kwargs in calls] == ["patients", "orders", "progress"]
for _name, kwargs in calls:
assert kwargs["page_no"] == 1
assert kwargs["page_size"] == 15
assert "page" not in kwargs
patient.close()
orders.close()
progress.close()
application.processEvents()
def test_workspace_workers_use_gui_thread_query_snapshots(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[tuple[Any, tuple[Any, ...], dict[str, Any]]] = []
patient_calls: list[dict[str, Any]] = []
order_calls: list[dict[str, Any]] = []
def queue_async(function: Any, *args: Any, **options: Any) -> object:
queued.append((function, args, options))
return object()
class Repository:
def list_patients(self, **kwargs: Any) -> dict[str, Any]:
patient_calls.append(kwargs)
return {"lists": [], "count": 0}
def patient_orders(self, **kwargs: Any) -> dict[str, Any]:
order_calls.append(kwargs)
return {"lists": [], "count": 0}
repository = Repository()
patient = PatientListWorkspace(repository, PermissionSet(["*"]))
orders = PatientOrdersWorkspace(repository, PermissionSet(["*"]))
monkeypatch.setattr(patients_module, "run_async", queue_async)
patient.keyword_edit.setText("captured patient")
patient.refresh()
patient.keyword_edit.setText("changed patient")
function, args, _options = queued[0]
function(*args)
orders.keyword_edit.setText("captured order")
orders.refresh()
orders.keyword_edit.setText("changed order")
function, args, _options = queued[1]
function(*args)
assert patient_calls[0]["keyword"] == "captured patient"
assert order_calls[0]["keyword"] == "captured order"
patient.close()
orders.close()
application.processEvents()
def test_appointment_form_uses_rosters_slots_and_diagnosis_id_contract(
application: QApplication,
immediate_async: None,
) -> None:
tomorrow = QDate.currentDate().addDays(1).toString("yyyy-MM-dd")
appointment_queries: list[dict[str, Any]] = []
roster_queries: list[dict[str, Any]] = []
slot_queries: list[dict[str, Any]] = []
class Repository:
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 77, "name": "陈医生", "department_name": "中医科"}]
def get_dictionary(self, dictionary_type: str) -> list[dict[str, Any]]:
assert dictionary_type == "channels"
return [{"id": 1, "name": "线上复诊", "value": "online", "status": 1, "sort": 10}]
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
appointment_queries.append(kwargs)
return {"lists": [], "count": 0}
def list_appointment_rosters(self, **kwargs: Any) -> dict[str, Any]:
roster_queries.append(kwargs)
return {"lists": [{"date": tomorrow}], "count": 1}
def get_available_appointment_slots(self, **kwargs: Any) -> dict[str, Any]:
slot_queries.append(kwargs)
return {"slots": [{"time": "09:30-10:00", "available": True, "quota": 2}]}
row = {
"id": 501,
"diagnosis_id": 501,
"patient_id": 999,
"source_patient_id": 999,
"patient_name": "林晓岚",
"doctor_id": 77,
}
dialog = _AppointmentDialog(row, repository=Repository())
application.processEvents()
dialog.channel_source.setCurrentIndex(dialog.channel_source.findData("online"))
dialog.slot_combo.setCurrentIndex(dialog.slot_combo.findData("09:30-10:00"))
dialog.remark.setPlainText("复诊预约")
application.processEvents()
payload = dialog.payload()
assert appointment_queries[0]["patient_id"] == 501
assert roster_queries[0]["doctor_id"] == 77
assert slot_queries[0] == {
"doctor_id": 77,
"appointment_date": tomorrow,
"period": "all",
}
assert dialog.ok_button.isEnabled()
assert payload == {
"diagnosis_id": 501,
"patient_id": 501,
"doctor_id": 77,
"appointment_date": tomorrow,
"appointment_time": "09:30-10:00",
"period": "all",
"appointment_type": "video",
"remark": "复诊预约",
"channel_source": "online",
"channel_source_detail": "",
}
assert payload["patient_id"] != row["source_patient_id"]
dialog.close()
application.processEvents()
def test_payment_and_refund_forms_expose_full_contract(
application: QApplication,
) -> None:
payment = _PaymentDialog({"amount": 368, "linked_pay_paid_total": 100})
payment.pay_create_type.setCurrentIndex(payment.pay_create_type.findData("express_cod"))
payment.pay_amount.setValue(268)
payment.pay_remark.setPlainText("货到代收")
payment.completion_request.setChecked(True)
assert payment.payload() == {
"order_type": 3,
"pay_amount": 268.0,
"pay_remark": "货到代收",
"completion_request": 1,
"pay_create_type": "express_cod",
}
refund = _RefundDialog({"amount": 368})
refund.reason.setPlainText("患者取消")
assert refund.payload() == {"reason": "患者取消", "refund_amount": None}
refund.specify_amount.setChecked(True)
refund.refund_amount.setValue(88.5)
assert refund.payload() == {"reason": "患者取消", "refund_amount": 88.5}
payment.close()
refund.close()
application.processEvents()
def test_out_of_order_mutation_successes_each_trigger_reconciliation(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
page = PatientsPage(SimpleNamespace(), permissions=PermissionSet(["*"]))
callbacks: list[dict[str, Any]] = []
reconciliations: list[str] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(patients_module, "run_async", queue_async)
monkeypatch.setattr(page, "_after_mutation", lambda: reconciliations.append("refresh"))
page._run_action("first", lambda: None, success="first done")
page._run_action("second", lambda: None, success="second done")
callbacks[1]["on_success"](None)
callbacks[0]["on_success"](None)
callbacks[1]["on_finished"]()
callbacks[0]["on_finished"]()
assert reconciliations == ["refresh", "refresh"]
assert page._pending_action_tokens == set()
page.close()
application.processEvents()
def test_diagnosis_dialog_loads_histories_and_saves_canonical_fields(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
diagnosis_id = repository.list_patients().items[0].diagnosis_id
before = repository.get_diagnosis_detail(diagnosis_id)
dialog = DiagnosisDialog(repository)
dialog.open_for(diagnosis_id, editable=True)
application.processEvents()
assert (
dialog.edit_fields["chief_complaint"].toPlainText()
== before["diagnosis"]["chief_complaint"]
)
assert dialog.appointment_table.rowCount() >= 1
dialog.edit_fields["chief_complaint"].setPlainText("离屏回归主诉")
dialog._save()
assert (
repository.get_diagnosis_detail(diagnosis_id)["diagnosis"]["chief_complaint"]
== "离屏回归主诉"
)
dialog.close()
application.processEvents()
def test_order_action_matrix_requires_exact_permissions_and_states(
application: QApplication,
) -> None:
codes = {
"tcm.prescriptionOrder/detail",
"tcm.prescriptionOrder/edit",
"tcm.prescriptionOrder/auditPrescription",
"tcm.prescriptionOrder/auditPayment",
"tcm.prescriptionOrder/ddcode",
"tcm.prescriptionOrder/ship",
"tcm.prescriptionOrder/addPayOrder",
"tcm.prescriptionOrder/complete",
"tcm.prescriptionOrder/refund",
"tcm.prescriptionOrder/withdraw",
"tcm.prescriptionOrder/uploadToPharmacy",
}
workspace = PatientOrdersWorkspace(SimpleNamespace(), PermissionSet(codes))
pending = {
"id": 1,
"fulfillment_status": 1,
"prescription_audit_status": 0,
"payment_slip_audit_status": 0,
"amount": 100,
"linked_pay_paid_total": 0,
}
shipped = {
**pending,
"fulfillment_status": 5,
"prescription_audit_status": 1,
"payment_slip_audit_status": 1,
"linked_pay_paid_total": 80,
}
assert [key for key, _label, _danger in workspace._available_actions(pending)] == [
"edit",
"audit_prescription",
"ddcode",
"withdraw",
]
assert [key for key, _label, _danger in workspace._available_actions(shipped)] == [
"revoke_pay_audit",
"ddcode",
"add_pay_order",
"complete",
"refund",
"upload_pharmacy",
]
remote_locked = {**pending, "gancao_reciperl_order_no": "GC-REMOTE-1"}
assert [key for key, _label, _danger in workspace._available_actions(remote_locked)] == [
"audit_prescription",
"ddcode",
]
alias_only = PatientOrdersWorkspace(
SimpleNamespace(), PermissionSet(["tcm.prescriptionOrder.edit"])
)
assert alias_only._available_actions(pending) == []
workspace.close()
alias_only.close()
application.processEvents()
def test_order_audit_action_uses_repository_contract_values(
application: QApplication,
immediate_async: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
page = PatientsPage(repository, permissions=session.permissions, current_user=session.user)
order_id = repository.patient_orders().items[0]["id"]
repository.revoke_patient_order_payment_audit(order_id)
repository.revoke_patient_order_prescription_audit(order_id)
row = repository.get_patient_order(order_id)
monkeypatch.setattr(
QInputDialog,
"getItem",
staticmethod(lambda *_args, **_kwargs: ("通过", True)),
)
monkeypatch.setattr(
QInputDialog,
"getText",
staticmethod(lambda *_args, **_kwargs: ("离屏审核", True)),
)
page._handle_order_action("audit_prescription", row)
assert repository.get_patient_order(row["id"])["prescription_audit_status"] == 1
page.close()
application.processEvents()
def test_shell_uses_demo_session_menu_and_fits_minimum_window(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
session = repository.login(repository.DEMO_ACCOUNT, repository.DEMO_PASSWORD)
patient_menu = next(
row for row in session.menu if row.get("perms") == "firstvisit.myPatient/lists"
)
patient_menu["name"] = "患者中心"
patient_menu["sort"] = 99
session.menu = [patient_menu]
shell = ShellWindow(
repository,
{"session": session, "demo_mode": True},
permissions=session.permissions,
)
shell.resize(1024, 640)
shell.show()
application.processEvents()
assert list(shell.pages) == ["patients"]
assert shell.nav_buttons["patients"].text().endswith("患者中心")
assert shell.minimumWidth() == 1024
assert shell.minimumHeight() == 640
assert shell.size().width() == 1024
assert shell.size().height() == 640
assert {item.key for item in NAVIGATION} == {
"reception",
"prescription_library",
"prescriptions",
"patients",
"consultations",
}
shell.close()
application.processEvents()
+52
View File
@@ -0,0 +1,52 @@
"""Tests for permission semantics reused by page and action guards."""
from doctor_workstation.core.permissions import PermissionSet
def test_global_wildcard_grants_every_page_and_action() -> None:
"""The server's global wildcard remains authoritative."""
permissions = PermissionSet(["*"])
assert permissions.is_superuser
assert permissions.can_access_page("doctor.appointment/lists")
assert permissions.can_perform_action("doctor.appointment/complete")
assert permissions.can("doctor.appointment", "complete")
def test_all_and_any_accept_sequences_or_positional_values() -> None:
"""AND and OR helpers preserve the two web-client permission semantics."""
permissions = PermissionSet(["doctor.appointment/lists", "doctor.appointment/complete"])
assert permissions.all("doctor.appointment/lists", "doctor.appointment/complete")
assert permissions.has_all(["doctor.appointment/lists", "doctor.appointment/complete"])
assert not permissions.all("doctor.appointment/lists", "doctor.appointment/cancel")
assert permissions.any(["doctor.appointment/cancel", "doctor.appointment/complete"])
assert not permissions.has_any([])
assert permissions.has_all([])
def test_page_action_categories_and_resource_wildcards() -> None:
"""Explicit page/action categories share checks without overloading UI code."""
permissions = PermissionSet(
pages=["patients/view"],
actions=["tcm.prescriptionLibrary/*"],
)
assert permissions.can_access_page("patients/view")
assert permissions.can("tcm.prescriptionLibrary", "add")
assert permissions.can_perform_action("tcm.prescriptionLibrary/delete")
assert not permissions.can_perform_action("tcm.prescription/delete")
assert "patients/view" in permissions
def test_permission_set_normalises_and_deduplicates_values() -> None:
"""Whitespace and duplicates do not create surprising guard results."""
permissions = PermissionSet([" alpha/read ", "alpha/read", ""])
assert list(permissions) == ["alpha/read"]
assert len(permissions) == 1
assert bool(permissions)
+386
View File
@@ -0,0 +1,386 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QDialog
from doctor_workstation.core import PermissionSet
from doctor_workstation.ui import widgets
from doctor_workstation.ui.dialogs import diagnosis as diagnosis_module
from doctor_workstation.ui.dialogs import prescription as dialog_module
from doctor_workstation.ui.dialogs.diagnosis import DiagnosisDialog
from doctor_workstation.ui.dialogs.prescription import (
PrescriptionEditorDialog,
PrescriptionOrderDialog,
PrescriptionTemplateDialog,
)
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescription_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import PrescriptionsPage
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
def _immediate_async(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**_kwargs: Any,
) -> object:
try:
result = function(*args)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
class _DiagnosisRepository:
def __init__(self) -> None:
self.order_queries: list[dict[str, Any]] = []
self.phone_checks: list[dict[str, Any]] = []
self.id_card_checks: list[dict[str, Any]] = []
self.updates: list[tuple[int, dict[str, Any]]] = []
def get_diagnosis_detail(self, diagnosis_id: int, *, readonly: bool = False) -> dict[str, Any]:
del readonly
return {
"diagnosis": {
"id": diagnosis_id,
"patient_id": 321,
"patient_name": "林晓岚",
"phone": "13812345678",
"id_card": "510107199001011234",
"gender": 0,
"age": 36,
"height": 162.5,
"weight": 52.0,
"fasting_blood_sugar": "6.2",
"chief_complaint": "乏力",
"symptoms": "口干",
"appetite": ["一般", "少食"],
"clinical_diagnosis": "气阴两虚",
"can_edit_patient_basic": True,
},
"patient": {"id": 321},
"appointment": {},
}
def appointment_history(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def assign_history(self, **_kwargs: Any) -> dict[str, Any]:
return {"lists": [], "count": 0}
def list_prescription_orders(self, **kwargs: Any) -> dict[str, Any]:
self.order_queries.append(kwargs)
return {
"lists": [
{
"id": 8,
"order_no": "ORDER-8",
"prescription_id": 5,
"patient_name": "林晓岚",
"amount": 128,
}
],
"count": 1,
}
def check_diagnosis_phone(self, payload: dict[str, Any]) -> dict[str, Any]:
self.phone_checks.append(payload)
return {"exists": False}
def check_diagnosis_id_card(self, payload: dict[str, Any]) -> dict[str, Any]:
self.id_card_checks.append(payload)
return {"duplicate": False}
def update_diagnosis(
self, diagnosis: int, changes: dict[str, Any] | None = None
) -> dict[str, Any]:
self.updates.append((diagnosis, dict(changes or {})))
return {"id": diagnosis, **dict(changes or {})}
def test_canonical_permission_helper_rejects_dot_aliases_and_accepts_wildcards() -> None:
assert not widgets.has_permission(
PermissionSet(["cf.prescription.edit"]), "cf.prescription/edit"
)
assert widgets.has_permission(PermissionSet(["cf.prescription/*"]), "cf.prescription/edit")
assert widgets.has_permission(PermissionSet(["*"]), "cf.prescription/edit")
assert not widgets.has_permission({}, "cf.prescription/edit")
def test_diagnosis_masks_sensitive_fields_and_loads_exact_context_orders(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
repository = _DiagnosisRepository()
dialog = DiagnosisDialog(
repository,
permissions=PermissionSet(["tcm.diagnosis/patientOrders"]),
)
dialog.open_for(77, editable=False)
assert dialog.summary_fields["phone"].text() == "138****5678"
assert dialog.summary_fields["id_card"].text() == "510107********1234"
assert dialog.edit_fields["phone"].toPlainText() == "138****5678"
assert dialog.edit_fields["id_card"].toPlainText() == "510107********1234"
assert dialog.edit_fields["phone"].isReadOnly()
assert dialog.edit_fields["id_card"].isReadOnly()
assert dialog.orders_table.rowCount() == 1
assert repository.order_queries == [
{
"page_no": 1,
"page_size": 10,
"context_diagnosis_id": 77,
"patient_id": 321,
"scene": "diagnosis_edit",
}
]
dialog.close()
application.processEvents()
def test_diagnosis_edit_checks_unique_identity_and_saves_expanded_dto(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(diagnosis_module, "run_async", _immediate_async)
repository = _DiagnosisRepository()
dialog = DiagnosisDialog(
repository,
permissions=PermissionSet(["tcm.diagnosis/edit", "tcm.diagnosis/phonePlain"]),
)
dialog.open_for(77, editable=True)
dialog.edit_fields["symptoms"].setPlainText("口干、多饮")
dialog.edit_fields["appetite"].setPlainText("一般、少食")
dialog._save()
assert repository.phone_checks == [{"phone": "13812345678", "id": 77}]
assert repository.id_card_checks == [{"id_card": "510107199001011234", "id": 77}]
diagnosis_id, changes = repository.updates[-1]
assert diagnosis_id == 77
assert changes["phone"] == "13812345678"
assert changes["id_card"] == "510107199001011234"
assert changes["symptoms"] == "口干、多饮"
assert changes["appetite"] == ["一般", "少食"]
dialog.close()
application.processEvents()
def test_duplicate_herbs_are_rejected_for_templates_and_issued_prescriptions(
application: QApplication,
) -> None:
herbs = [
{"medicine_id": 11, "name": "黄芪", "dosage": 10},
{"medicine_id": 11, "name": "黄芪", "dosage": 15},
]
repository = SimpleNamespace()
template = PrescriptionTemplateDialog(
repository,
{"id": 1, "prescription_name": "重复方", "herbs": herbs},
mode="edit",
)
template.accept()
assert template.result() == QDialog.DialogCode.Rejected
assert "药材不可重复:黄芪" in template.validation.label.text()
editor = PrescriptionEditorDialog(
repository,
mode="add",
current_user=SimpleNamespace(id=9, name="周医生"),
)
editor.patient_name.setText("林晓岚")
editor.clinical_diagnosis.setPlainText("气虚")
editor.signature._has_strokes = True
editor.herbs.set_rows(herbs, locked=False)
editor.accept()
assert editor.result() == QDialog.DialogCode.Rejected
assert "药材不可重复:黄芪" in editor.validation.label.text()
template.close()
editor.close()
application.processEvents()
def test_paid_order_response_is_bound_to_active_diagnosis_and_blocks_save(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(dialog_module, "run_async", queue_async)
monkeypatch.setattr(dialog_module.QTimer, "singleShot", staticmethod(lambda *_args: None))
dialog = PrescriptionOrderDialog(
SimpleNamespace(list_paid_prescription_orders=lambda diagnosis_id: {}),
{"id": 12, "diagnosis_id": 6, "patient_name": "林晓岚"},
)
dialog._load_paid_orders()
assert not dialog.save_button.isEnabled()
dialog.diagnosis_id.setValue(7)
assert len(callbacks) == 2
callbacks[0]["on_success"](
{"lists": [{"id": 66, "order_no": "OLD"}], "deposit_min_amount": 100}
)
assert dialog.paid_orders.count() == 0
assert not dialog.save_button.isEnabled()
callbacks[1]["on_success"]({"lists": [{"id": 77, "order_no": "NEW"}], "deposit_min_amount": 50})
assert dialog.paid_orders.item(0).data(Qt.ItemDataRole.UserRole) == 77
assert dialog.save_button.isEnabled()
assert dialog._paid_orders_diagnosis_id == 7
dialog.close()
application.processEvents()
def _finish_queued(callback: dict[str, Any], result: Any) -> None:
callback["on_success"](result)
if callback.get("on_finished"):
callback["on_finished"]()
def test_prescription_lists_snapshot_queries_and_replay_pending_refresh(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
library_callbacks: list[tuple[Any, dict[str, Any]]] = []
library_calls: list[dict[str, Any]] = []
def queue_library(function: Any, **options: Any) -> object:
library_callbacks.append((function, options))
return object()
class LibraryRepository:
def list_prescription_templates(self, **kwargs: Any) -> dict[str, Any]:
library_calls.append(kwargs)
return {"lists": [], "count": 0}
monkeypatch.setattr(library_module, "run_async", queue_library)
library = PrescriptionLibraryPage(
LibraryRepository(), PermissionSet(["wcf.prescription/*"]), SimpleNamespace(id=1)
)
library.refresh()
library.name_filter.setText("新条件")
library.refresh()
assert len(library_callbacks) == 1
first_function, first_options = library_callbacks[0]
first_result = first_function()
_finish_queued(first_options, first_result)
assert len(library_callbacks) == 2
second_function, second_options = library_callbacks[1]
second_result = second_function()
_finish_queued(second_options, second_result)
assert [call["prescription_name"] for call in library_calls] == ["", "新条件"]
issued_callbacks: list[tuple[Any, dict[str, Any]]] = []
issued_calls: list[dict[str, Any]] = []
def queue_issued(function: Any, **options: Any) -> object:
issued_callbacks.append((function, options))
return object()
class IssuedRepository:
def list_prescriptions(self, **kwargs: Any) -> dict[str, Any]:
issued_calls.append(kwargs)
return {"lists": [], "count": 0}
monkeypatch.setattr(prescription_module, "run_async", queue_issued)
issued = PrescriptionsPage(
IssuedRepository(), PermissionSet(["cf.prescription/*"]), SimpleNamespace(id=1)
)
issued.refresh()
issued.patient_filter.setText("新患者")
issued.refresh()
assert len(issued_callbacks) == 1
first_function, first_options = issued_callbacks[0]
first_result = first_function()
_finish_queued(first_options, first_result)
assert len(issued_callbacks) == 2
second_function, second_options = issued_callbacks[1]
second_result = second_function()
_finish_queued(second_options, second_result)
assert [call["patient_name"] for call in issued_calls] == ["", "新患者"]
library.close()
issued.close()
application.processEvents()
def test_diagnosis_detail_requires_permission_and_ignores_stale_target(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository_calls: list[int] = []
class Repository:
def get_diagnosis_detail(
self, diagnosis_id: int, *, readonly: bool = False
) -> dict[str, Any]:
repository_calls.append(diagnosis_id)
return {"id": diagnosis_id, "readonly": readonly}
denied = PrescriptionsPage(Repository(), PermissionSet([]), SimpleNamespace(id=1))
denied._open_diagnosis(1)
assert repository_calls == []
callbacks: list[tuple[Any, dict[str, Any]]] = []
def queue_async(function: Any, **options: Any) -> object:
callbacks.append((function, options))
return object()
shown: list[int] = []
class FakeDiagnosisDetailDialog:
def __init__(self, detail: dict[str, Any], _parent: Any) -> None:
shown.append(detail["id"])
def exec(self) -> int:
return 0
monkeypatch.setattr(prescription_module, "run_async", queue_async)
monkeypatch.setattr(prescription_module, "DiagnosisDetailDialog", FakeDiagnosisDetailDialog)
allowed = PrescriptionsPage(
Repository(),
PermissionSet(["tcm.diagnosis/readonlyDetail"]),
SimpleNamespace(id=1),
)
allowed._open_diagnosis(11)
allowed._open_diagnosis(12)
assert len(callbacks) == 2
old_function, old_options = callbacks[0]
old_options["on_success"](old_function())
assert shown == []
new_function, new_options = callbacks[1]
new_options["on_success"](new_function())
assert shown == [12]
assert repository_calls == [11, 12]
denied.close()
allowed.close()
application.processEvents()
+424
View File
@@ -0,0 +1,424 @@
from __future__ import annotations
import os
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui.dialogs import prescription as dialog_module
from doctor_workstation.ui.dialogs.prescription import (
AuditPrescriptionDialog,
PrescriptionDetailDialog,
PrescriptionEditorDialog,
PrescriptionOrderDialog,
PrescriptionTemplateDialog,
RemoteMedicineComboBox,
parse_pasted_herbs,
render_prescription_html,
)
from doctor_workstation.ui.pages import prescription_library as library_module
from doctor_workstation.ui.pages import prescriptions as prescription_module
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
from doctor_workstation.ui.pages.prescriptions import (
PrescriptionsPage,
can_audit,
can_create_order,
can_edit_or_delete,
can_patch_patient,
prescription_status,
)
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(dialog_module, "run_async", run_immediately)
monkeypatch.setattr(library_module, "run_async", run_immediately)
monkeypatch.setattr(prescription_module, "run_async", run_immediately)
def test_admin_status_and_action_guards_are_exact() -> None:
pending = {
"id": 1,
"audit_status": 0,
"void_status": 0,
"has_prescription_order": 0,
}
approved = {**pending, "audit_status": 1}
rejected = {
**approved,
"business_prescription_audit_rejected": 1,
"business_prescription_audit_remark": "剂量需调整",
}
voided = {**approved, "void_status": 1}
assert prescription_status(pending) == ("待审核", "warning")
assert prescription_status(approved) == ("已通过", "success")
assert prescription_status(rejected) == ("已驳回", "danger")
assert prescription_status(voided) == ("已作废", "danger")
assert can_patch_patient(pending)
assert can_create_order(pending)
assert can_audit(pending)
assert can_edit_or_delete(pending)
assert not can_audit(approved)
assert not can_edit_or_delete(approved)
assert can_edit_or_delete(voided)
assert not can_patch_patient(voided)
def test_paste_parser_matches_common_admin_recipe_forms() -> None:
parsed = parse_pasted_herbs("Rp: 黄芪15 党参12、茯苓10g\n柴胡、白术各6克\n饭后温服")
assert parsed == [
{"name": "黄芪", "dosage": 15.0},
{"name": "党参", "dosage": 12.0},
{"name": "茯苓", "dosage": 10.0},
{"name": "柴胡", "dosage": 6.0},
{"name": "白术", "dosage": 6.0},
]
def test_remote_medicine_selector_rejects_new_free_text(
application: QApplication,
) -> None:
repository = SimpleNamespace(list_medicines=lambda **_kwargs: {"lists": [], "count": 0})
selector = RemoteMedicineComboBox(repository, name="历史药名")
assert selector.has_valid_selection
selector._queue_search("随意输入")
selector._timer.stop()
selector.setEditText("随意输入")
assert not selector.has_valid_selection
selector.set_value(11, "黄芪")
assert selector.has_valid_selection
selector.close()
application.processEvents()
def test_template_disable_edit_controls_import_not_template_maintenance(
application: QApplication,
immediate_async: None,
) -> None:
repository = SimpleNamespace(
list_medicines=lambda **_kwargs: {
"lists": [{"id": 11, "name": "黄芪"}],
"count": 1,
}
)
template = {
"id": 8,
"prescription_name": "益气方",
"formula_type": "主方",
"is_public": 1,
"disable_edit": 1,
"herbs": [{"medicine_id": 11, "name": "黄芪", "dosage": 15}],
}
dialog = PrescriptionTemplateDialog(repository, template, mode="edit")
assert dialog.disable_edit_check.isChecked()
assert dialog.herbs.rows[0].medicine.isEnabled()
assert dialog.herbs.rows[0].dosage.isEnabled()
assert dialog.payload() == {
"id": 8,
"prescription_name": "益气方",
"formula_type": "主方",
"is_public": 1,
"disable_edit": 1,
"herbs": [{"medicine_id": 11, "name": "黄芪", "dosage": 15.0}],
}
dialog.close()
application.processEvents()
def test_library_page_uses_canonical_permissions_and_full_columns(
application: QApplication,
immediate_async: None,
) -> None:
class Repository:
def list_prescription_templates(self, **_filters: Any) -> dict[str, Any]:
return {
"lists": [
{
"id": 3,
"prescription_name": "安神方",
"formula_type": "辅方",
"herbs": [{"name": "酸枣仁", "dosage": 12}],
"is_public": 0,
"disable_edit": 1,
"creator_id": 9,
"creator_name": "张医生",
"create_time": "2026-08-10 12:00:00",
}
],
"count": 1,
}
permissions = {
"wcf.prescription/add",
"wcf.prescription/read",
"wcf.prescription/edit",
"wcf.prescription/delete",
}
page = PrescriptionLibraryPage(
Repository(),
permissions,
SimpleNamespace(id=9, root=0, role_ids=[]),
)
page.refresh()
page.table.selectRow(0)
page._selection_changed()
assert page.table.columnCount() == 9
assert not page.view_button.isHidden() and page.view_button.isEnabled()
assert not page.edit_button.isHidden() and page.edit_button.isEnabled()
assert not page.delete_button.isHidden() and page.delete_button.isEnabled()
assert page.pager.page_size == 15
page.close()
application.processEvents()
def test_issued_page_sends_exact_filter_dto_and_row_guards(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
pending = {
"id": 21,
"sn": "CF-21",
"patient_name": "林晓岚",
"gender": 0,
"age": 33,
"audit_status": 0,
"void_status": 0,
"has_prescription_order": 0,
"creator_id": 7,
"doctor_name": "周医生",
"prescription_date": "2026-08-10",
"create_time": "2026-08-10 12:00:00",
"herbs": [{"name": "黄芪", "dosage": 15}],
}
class Repository:
def list_diagnosis_doctors(self) -> list[dict[str, Any]]:
return [{"id": 9, "name": "孙医生"}]
def list_prescriptions(self, **filters: Any) -> dict[str, Any]:
calls.append(filters)
return {"lists": [pending], "count": 1}
permissions = {
"cf.prescription/add",
"cf.prescription/read",
"cf.prescription/edit",
"cf.prescription/audit",
"cf.prescription/del",
"tcm.prescription/patchPatient",
"tcm.prescriptionOrder/create",
"tcm.prescriptionOrder/lists",
}
page = PrescriptionsPage(
Repository(),
permissions,
SimpleNamespace(id=7, name="周医生"),
)
page.refresh()
page.table.selectRow(0)
page._selection_changed()
assert calls == [
{
"page_no": 1,
"page_size": 15,
"sn": "",
"patient_name": "",
"audit_filter": "",
"source_filter": "",
"start_time": "",
"end_time": "",
}
]
page.quick_date.setCurrentIndex(1)
assert len(calls) == 2
assert calls[-1]["start_time"].endswith("00:00:00")
assert calls[-1]["end_time"].endswith("23:59:59")
assert page.audit_button.isEnabled()
assert page.doctor_filter._options[9] == "孙医生"
assert page.patch_button.isEnabled()
assert page.create_order_button.isEnabled()
assert page.edit_button.isEnabled()
assert page.delete_button.isEnabled()
page.table.set_rows([{**pending, "audit_status": 1}])
page.table.selectRow(0)
page._selection_changed()
assert not page.audit_button.isEnabled()
assert not page.edit_button.isEnabled()
assert not page.delete_button.isEnabled()
assert page.patch_button.isEnabled()
assert page.create_order_button.isEnabled()
page.close()
application.processEvents()
def test_editor_builds_complete_add_payload(
application: QApplication,
immediate_async: None,
) -> None:
repository = SimpleNamespace(
list_medicines=lambda **_kwargs: {
"lists": [{"id": 31, "name": "黄芪"}],
"count": 1,
}
)
user = SimpleNamespace(id=7, name="周医生")
editor = PrescriptionEditorDialog(repository, mode="add", current_user=user)
editor.patient_name.setText("林晓岚")
editor.clinical_diagnosis.setPlainText("脾气虚")
editor.herbs.rows[0].medicine.set_value(31, "黄芪")
editor.herbs.rows[0].dosage.setValue(15)
editor.signature._has_strokes = True
payload = editor.payload()
assert payload["creator_id"] == 7
assert payload["audit_status"] == 0
assert payload["patient_name"] == "林晓岚"
assert payload["clinical_diagnosis"] == "脾气虚"
assert payload["herbs"] == [
{
"medicine_id": 31,
"name": "黄芪",
"dosage": 15.0,
"formula_type": "主方",
}
]
assert payload["doctor_name"] == "周医生"
assert payload["doctor_signature"].startswith("data:image/png;base64,")
assert isinstance(payload["aux_usage"], dict)
editor.close()
application.processEvents()
def test_audit_reject_requires_remark(
application: QApplication,
) -> None:
dialog = AuditPrescriptionDialog({"id": 4})
dialog._choose("reject")
assert dialog.action == ""
assert not dialog.banner.isHidden()
dialog.remark.setPlainText("剂量需调整")
dialog._choose("reject")
assert dialog.action == "reject"
assert dialog.payload() == {
"id": 4,
"action": "reject",
"remark": "剂量需调整",
}
dialog.close()
application.processEvents()
def test_order_payload_and_a4_print_document(
application: QApplication,
immediate_async: None,
) -> None:
repository = SimpleNamespace(
list_paid_prescription_orders=lambda diagnosis_id: {
"lists": [{"id": 88, "order_no": "PAY-88", "amount": 100}],
"deposit_min_amount": 100,
}
)
prescription = {
"id": 12,
"diagnosis_id": 6,
"sn": "CF-12",
"patient_name": "林晓岚",
"phone": "13800000000",
"gender": 0,
"age": 33,
"clinical_diagnosis": "脾气虚",
"doctor_name": "周医生",
"prescription_date": "2026-08-10",
"audit_status": 1,
"dose_count": 7,
"dose_unit": "",
"usage_days": 7,
"times_per_day": 2,
"herbs": [
{"name": "黄芪", "dosage": 15, "formula_type": "主方"},
{"name": "酸枣仁", "dosage": 12, "formula_type": "辅方"},
],
}
order = PrescriptionOrderDialog(repository, prescription)
order._load_paid_orders()
order.shipping_province.setText("四川省")
order.shipping_city.setText("成都市")
order.shipping_district.setText("双流区")
order.shipping_address.setText("黄龙大道 280 号")
order.amount.setValue(100)
order.paid_orders.item(0).setCheckState(Qt.CheckState.Checked)
payload = order.payload()
assert payload["prescription_id"] == 12
assert payload["diagnosis_id"] == 6
assert payload["pay_order_ids"] == [88]
assert payload["amount"] == 100
assert payload["ship_mode"] == "gancao"
rendered = render_prescription_html(prescription)
assert "林晓岚" in rendered
assert "黄芪" in rendered
assert "酸枣仁" in rendered
viewer = PrescriptionDetailDialog(prescription)
assert "中医处方笺" in viewer.document.toHtml()
viewer.close()
order.close()
application.processEvents()
def test_demo_repository_pages_render_offscreen(
application: QApplication,
immediate_async: None,
) -> None:
repository = DemoDoctorRepository()
user = SimpleNamespace(id=1, name="演示医生", root=1, role_ids=[0])
library = PrescriptionLibraryPage(repository, None, user)
issued = PrescriptionsPage(repository, None, user)
library.refresh()
issued.refresh()
assert library.table.rowCount() > 0
assert issued.table.rowCount() > 0
library.close()
issued.close()
application.processEvents()
+546
View File
@@ -0,0 +1,546 @@
from __future__ import annotations
import json
import os
from datetime import date
from pathlib import Path
from typing import Any
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtWidgets import QApplication
from doctor_workstation.core import PermissionSet
from doctor_workstation.services.api_client import ApiClient
from doctor_workstation.services.repository import RemoteDoctorRepository
from doctor_workstation.ui.pages import reception as reception_module
from doctor_workstation.ui.pages.reception import NOTE_LIMIT, ReceptionPage
@pytest.fixture(scope="module")
def application() -> QApplication:
return QApplication.instance() or QApplication([])
@pytest.fixture
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
def run_immediately(
function: Any,
*args: Any,
on_success: Any = None,
on_error: Any = None,
on_finished: Any = None,
**kwargs: Any,
) -> object:
try:
result = function(*args, **kwargs)
except Exception as error:
if on_error:
on_error(error)
else:
if on_success:
on_success(result)
finally:
if on_finished:
on_finished()
return object()
monkeypatch.setattr(reception_module, "run_async", run_immediately)
def _detail(
appointment_id: int,
*,
name: str,
status: int = 1,
phone: str = "13800138000",
) -> dict[str, Any]:
patient_id = appointment_id + 100
diagnosis_id = appointment_id + 200
return {
"appointment": {
"id": appointment_id,
"patient_id": patient_id,
"patient_name": name,
"status": status,
"appointment_date": date.today().isoformat(),
"appointment_time": "09:30",
"doctor_name": "张医生",
"assistant_name": "李医助",
"appointment_type_text": "复诊",
"channel_text": "线上",
"remark": "准时到诊",
},
"patient": {
"id": patient_id,
"phone": phone,
"gender": 2,
"age": 42,
"height": 165,
"weight": 55,
"region_text": "浙江省杭州市",
},
"diagnosis": {
"id": diagnosis_id,
"patient_id": patient_id,
"patient_name": name,
"phone": phone,
"chief_complaint": "反复口渴",
"present_illness": "持续两周",
"clinical_diagnosis": "消渴",
},
}
def test_queue_uses_admin_same_day_contract(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
page = ReceptionPage(Repository(), PermissionSet([]))
page.search_edit.setText(" 王小明 ")
page.refresh()
assert calls == [
{
"status": 1,
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 1,
"page_size": 15,
"patient_name": "王小明",
}
]
page.queue_tabs.setCurrentIndex(1)
assert calls[-1]["status"] == 4
assert calls[-1]["start_date"] == calls[-1]["end_date"]
page.close()
application.processEvents()
def test_fast_patient_switch_rejects_late_detail(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
callbacks: list[dict[str, Any]] = []
def queue_async(_function: Any, **options: Any) -> object:
callbacks.append(options)
return object()
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(object(), PermissionSet(["*"]))
first = {"id": 11, "patient_id": 111, "diagnosis_id": 211, "patient_name": "甲患者"}
second = {"id": 22, "patient_id": 122, "diagnosis_id": 222, "patient_name": "乙患者"}
page._select_record(first)
page.note_edit.setPlainText("甲患者的未保存草稿")
page._pending_report_files = [r"C:\records\first.pdf"]
page._select_record(second)
assert len(callbacks) == 2
assert page.patient_name_label.text() == "乙患者"
assert page.note_edit.toPlainText() == ""
assert page._pending_report_files == []
callbacks[0]["on_success"]({"detail": _detail(11, name="甲患者")})
callbacks[0]["on_finished"]()
assert page._selected_appointment_id == 22
assert page.patient_name_label.text() == "乙患者"
assert page._selected_detail is None
callbacks[1]["on_success"]({"detail": _detail(22, name="乙患者")})
callbacks[1]["on_finished"]()
assert page._selected_detail == _detail(22, name="乙患者")
assert page.patient_name_label.text() == "乙患者"
assert "反复口渴" in page.diagnosis_text.text()
page.close()
application.processEvents()
def test_queue_load_more_accumulates_to_total_boundary(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
all_rows = [
{
"id": index,
"patient_id": 1000 + index,
"diagnosis_id": 2000 + index,
"patient_name": f"患者{index:02d}",
"status": 1,
}
for index in range(1, 23)
]
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {
"lists": all_rows[start : start + kwargs["page_size"]],
"count": len(all_rows),
}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
row = all_rows[appointment_id - 1]
return {
"appointment": row,
"diagnosis": {"id": row["diagnosis_id"], "patient_id": row["patient_id"]},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
assert page.queue_list.count() == 15
assert page.load_more_button.isVisibleTo(page)
page._load_more()
assert [call["page_no"] for call in calls] == [1, 2]
assert all(call["page_size"] == 15 for call in calls)
assert page.queue_list.count() == 22
assert page.queue_summary.text() == "已加载 22 / 共 22 位患者"
assert page.load_more_button.isHidden()
page.close()
application.processEvents()
def test_search_and_tab_changes_reset_accumulated_pages(
application: QApplication,
immediate_async: None,
) -> None:
calls: list[dict[str, Any]] = []
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
if kwargs["status"] == 4:
rows = [{"id": 401, "patient_name": "过号患者", "status": 4}]
elif kwargs["patient_name"]:
rows = [{"id": 201, "patient_name": "搜索患者", "status": 1}]
else:
rows = [
{"id": index, "patient_name": f"患者{index}", "status": 1}
for index in range(1, 19)
]
start = (kwargs["page_no"] - 1) * kwargs["page_size"]
return {"lists": rows[start : start + kwargs["page_size"]], "count": len(rows)}
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": appointment_id + 1000,
"status": 1,
},
"diagnosis": {
"id": appointment_id + 2000,
"patient_id": appointment_id + 1000,
},
}
page = ReceptionPage(Repository(), PermissionSet([]))
page.refresh()
page._load_more()
assert page.queue_list.count() == 18
page.search_edit.setText("搜索")
page.refresh()
assert page.queue_list.count() == 1
assert page._queue_page == 1
assert calls[-1]["patient_name"] == "搜索"
page.queue_tabs.setCurrentIndex(1)
assert page.queue_list.count() == 1
assert page._queue_page == 1
assert calls[-1]["status"] == 4
page.close()
application.processEvents()
def test_queue_worker_uses_frozen_widget_snapshot(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs: list[Any] = []
calls: list[dict[str, Any]] = []
def queue_async(function: Any, **_options: Any) -> object:
jobs.append(function)
return object()
class Repository:
def list_appointments(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"lists": [], "count": 0}
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(Repository(), PermissionSet([]))
page.search_edit.setText("甲患者")
page.refresh()
page.search_edit.blockSignals(True)
page.search_edit.setText("乙患者")
page.search_edit.blockSignals(False)
page.queue_tabs.blockSignals(True)
page.queue_tabs.setCurrentIndex(1)
page.queue_tabs.blockSignals(False)
jobs[0]()
assert calls == [
{
"status": 1,
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 1,
"page_size": 15,
"patient_name": "甲患者",
}
]
page.close()
application.processEvents()
def test_phone_permission_and_ungated_notify_video_actions(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(31, name="脱敏患者")
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 31
return detail
masked_page = ReceptionPage(Repository(), PermissionSet([]))
masked_page._select_record(detail["appointment"])
assert masked_page.patient_labels["phone"].text() == "138****8000"
assert not masked_page.notify_button.isHidden()
assert not masked_page.video_button.isHidden()
plain_page = ReceptionPage(Repository(), PermissionSet(["tcm.diagnosis/phonePlain"]))
plain_page._select_record(detail["appointment"])
assert plain_page.patient_labels["phone"].text() == "13800138000"
assert plain_page.appointment_labels["doctor"].text() == "张医生"
assert plain_page.appointment_labels["assistant"].text() == "李医助"
assert plain_page.patient_labels["region"].text() == "浙江省杭州市"
masked_page.close()
plain_page.close()
application.processEvents()
def test_video_payload_keeps_three_identifiers_distinct(
application: QApplication,
immediate_async: None,
) -> None:
detail = _detail(41, name="视频患者")
class Repository:
def get_reception(self, appointment_id: int) -> dict[str, Any]:
assert appointment_id == 41
return detail
page = ReceptionPage(Repository(), PermissionSet([]))
page._select_record(detail["appointment"])
emitted: list[dict[str, Any]] = []
page.video_requested.connect(emitted.append)
page._request_video()
assert emitted == [
{
"source": "reception",
"appointment_id": 41,
"patient_id": 141,
"diagnosis_id": 241,
"patient_name": "视频患者",
"record": detail["appointment"],
}
]
page.close()
application.processEvents()
def test_completion_revalidates_server_status_before_write(
application: QApplication,
) -> None:
completed: list[int] = []
class Repository:
status = 3
def get_reception(self, appointment_id: int) -> dict[str, Any]:
return {
"appointment": {
"id": appointment_id,
"patient_id": 151,
"status": self.status,
}
}
def complete_appointment(self, appointment_id: int) -> dict[str, bool]:
completed.append(appointment_id)
return {"ok": True}
repository = Repository()
page = ReceptionPage(repository, PermissionSet(["doctor.appointment/complete"]))
with pytest.raises(ValueError, match="状态已变化"):
page._complete_after_revalidation(51)
assert completed == []
repository.status = 4
assert page._complete_after_revalidation(51) == {"ok": True}
assert completed == [51]
page.close()
application.processEvents()
def test_note_limit_and_attachment_payload_contract(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
jobs: list[tuple[Any, dict[str, Any]]] = []
received: list[dict[str, Any]] = []
uploads: list[dict[str, Any]] = []
def queue_async(function: Any, **options: Any) -> object:
jobs.append((function, options))
return object()
class Repository:
def upload_material(self, **kwargs: Any) -> str:
uploads.append(kwargs)
suffix = "tongue.jpg" if kwargs["material_type"] == "image" else "report.pdf"
return f"/uploads/{kwargs['material_type']}/{suffix}"
def add_doctor_note(self, diagnosis_id: int, content: str, **kwargs: Any) -> None:
received.append({"diagnosis_id": diagnosis_id, "content": content, **kwargs})
monkeypatch.setattr(reception_module, "run_async", queue_async)
page = ReceptionPage(Repository(), PermissionSet(["doctor.appointment/addDoctorNote"]))
detail = _detail(61, name="备注患者")
page._selected_record = detail["appointment"]
page._selected_appointment_id = 61
page._selected_detail = detail
page._detail_generation = 7
page.note_edit.setPlainText("" * (NOTE_LIMIT + 20))
page._pending_tongue_images = [r"C:\records\tongue.jpg"]
page._pending_report_files = [r"C:\records\report.pdf"]
assert len(page.note_edit.toPlainText()) == NOTE_LIMIT
assert page.note_counter.text() == f"{NOTE_LIMIT} / {NOTE_LIMIT}"
page._save_note()
assert len(jobs) == 1
jobs[0][0]()
assert uploads == [
{"path": r"C:\records\tongue.jpg", "material_type": "image", "cid": 0},
{"path": r"C:\records\report.pdf", "material_type": "file", "cid": 0},
]
assert received == [
{
"diagnosis_id": 261,
"content": "" * NOTE_LIMIT,
"tongue_images": ["/uploads/image/tongue.jpg"],
"report_files": ["/uploads/file/report.pdf"],
}
]
jobs[0][1]["on_finished"]()
assert not page._note_busy
page.close()
application.processEvents()
def test_remote_note_uses_multipart_then_server_urls_only(
application: QApplication,
tmp_path: Path,
) -> None:
requests: list[httpx.Request] = []
tongue = tmp_path / "tongue.jpg"
report = tmp_path / "report.pdf"
tongue.write_bytes(b"tongue-image")
report.write_bytes(b"report-file")
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path.endswith("/upload/image"):
return httpx.Response(
200,
json={"code": 1, "data": {"uri": "/materials/tongue.jpg"}},
)
if request.url.path.endswith("/upload/file"):
return httpx.Response(
200,
json={"code": 1, "data": {"url": "https://cdn.test/report.pdf"}},
)
return httpx.Response(200, json={"code": 1, "data": {"id": 9}})
with ApiClient(
"https://example.test",
transport=httpx.MockTransport(handler),
) as client:
repository = RemoteDoctorRepository(client)
page = ReceptionPage(repository, PermissionSet([]))
result = page._upload_and_add_note(
501,
"两阶段备注",
[str(tongue)],
[str(report)],
)
assert result == {"id": 9}
assert [request.url.path.rsplit("/", 2)[-2:] for request in requests] == [
["upload", "image"],
["upload", "file"],
["doctor.appointment", "addDoctorNote"],
]
for upload_request in requests[:2]:
assert upload_request.headers["content-type"].startswith("multipart/form-data; boundary=")
assert b'name="cid"' in upload_request.content
assert b"\r\n0\r\n" in upload_request.content
note_payload = json.loads(requests[-1].content)
assert note_payload == {
"diagnosis_id": 501,
"content": "两阶段备注",
"tongue_images": ["/materials/tongue.jpg"],
"report_files": ["https://cdn.test/report.pdf"],
}
assert str(tmp_path) not in requests[-1].content.decode("utf-8")
page.close()
application.processEvents()
def test_partial_upload_failure_never_submits_note(
application: QApplication,
) -> None:
submitted: list[dict[str, Any]] = []
class Repository:
def upload_material(self, path: str, material_type: str, cid: int = 0) -> str:
del material_type, cid
if path.endswith("bad.pdf"):
raise OSError("磁盘读取失败")
return "/materials/good.jpg"
def add_doctor_note(self, **kwargs: Any) -> None:
submitted.append(kwargs)
page = ReceptionPage(Repository(), PermissionSet([]))
with pytest.raises(RuntimeError, match="bad.pdf.*上传失败"):
page._upload_and_add_note(
501,
"不会提交",
[r"C:\records\good.jpg"],
[r"C:\records\bad.pdf"],
)
assert submitted == []
page.close()
application.processEvents()
+358
View File
@@ -0,0 +1,358 @@
"""No-network contracts for the audited five doctor workspaces."""
from __future__ import annotations
from datetime import date
from typing import Any
import pytest
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import (
PRESCRIPTION_LIBRARY_PERMISSIONS,
PRESCRIPTION_PERMISSIONS,
RemoteDoctorRepository,
)
class RecordingClient:
"""Small API client double retaining exact endpoint and DTO calls."""
def __init__(self) -> None:
self.base_url = "https://example.test/adminapi/"
self.token = "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:
"""Set the current synthetic token."""
self.token = token
def clear_token(self) -> None:
"""Clear the current synthetic token."""
self.token = ""
def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
"""Record a GET and return a shape appropriate for its contract."""
self.get_calls.append((endpoint, dict(params or {})))
if endpoint == "auth.admin/mySelf":
return {
"user": {"id": 1, "name": "Doctor", "role_ids": [1]},
"permissions": ["tcm.diagnosis/lists"],
"menu": [
{
"name": "问诊列表",
"component": "tcm/diagnosis/index",
"future": {"badge": 3},
"children": [{"name": "只读", "perms": "tcm.diagnosis/readonlyDetail"}],
"unsafe": lambda: None,
7: "non-string key",
}
],
}
if endpoint in {
"tcm.diagnosis/detail",
"tcm.diagnosis/readonlyDetail",
"firstvisit.myPatient/orderDetail",
"tcm.prescriptionOrder/detail",
}:
return {"id": int((params or {}).get("id", 0)), "patient_name": "测试患者"}
if endpoint == "tcm.prescription/getByAppointment":
return {}
if endpoint == "doctor.appointment/availableSlots":
return {"slots": [{"time": "09:00", "available": True}]}
if endpoint == "tcm.prescriptionOrder/paidPayOrders":
return {"lists": [{"id": 9}], "deposit_min_amount": 50}
return {"lists": [], "count": 0, "extend": {"scope": {"label": "server"}}}
def post(self, endpoint: str, payload: dict[str, Any] | None = None) -> Any:
"""Record a POST and return a stable synthetic mutation result."""
body = dict(payload or {})
self.post_calls.append((endpoint, body))
if endpoint in {"tcm.prescription/add", "tcm.prescriptionOrder/create"}:
return {"id": 88}
return {"ok": True}
def test_page_result_preserves_outer_and_nested_extend() -> None:
"""Server scope metadata must survive nested data pagination envelopes."""
page = PageResult.from_payload(
{
"extend": {"scope": {"label": "doctor"}},
"data": {
"lists": [{"id": 1}],
"count": 4,
"extend": {"schedule_mode": "roster"},
},
},
Appointment.from_dict,
)
assert page.total == 4
assert page.extend == {
"scope": {"label": "doctor"},
"schedule_mode": "roster",
}
def test_consultation_keeps_diagnosis_and_appointment_status_separate() -> None:
"""Video eligibility fields cannot be overwritten by diagnosis enablement."""
row = Consultation.from_dict(
{
"id": 3,
"status": 1,
"status_desc": "启用",
"has_appointment": 1,
"appointment_status": 4,
"appointment_status_text": "已过号",
"appointments": [{"id": 8, "status": 4}],
"DiagnosisViewRecord": [{"is_confirmed": 1}],
}
)
assert row.status == 1
assert row.appointment_status == 4
assert row.has_appointment
assert row.confirmed
assert row.appointments == [{"id": 8, "status": 4}]
def test_prescription_round_trips_appointment_and_case_record() -> None:
"""Appointment authority and immutable case snapshot survive model DTOs."""
prescription = Prescription.from_dict(
{
"id": 8,
"diagnosis_id": 5,
"appointment_id": 17,
"case_record": {
"diagnosis_id": 5,
"appointment_id": 17,
"clinical_diagnosis": "气阴两虚证",
},
}
)
assert prescription.appointment_id == 17
assert prescription.case_record["clinical_diagnosis"] == "气阴两虚证"
payload = prescription.to_api_dict()
assert payload["appointment_id"] == 17
assert payload["case_record"] == {
"diagnosis_id": 5,
"appointment_id": 17,
"clinical_diagnosis": "气阴两虚证",
}
def test_remote_reception_is_forcibly_scoped_to_today() -> None:
"""Status 1/4 queue reads always carry the audited same-day date range."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.list_appointments(status=1, keyword="", page_no=2, page_size=15)
endpoint, params = client.get_calls[-1]
assert endpoint == "doctor.appointment/lists"
assert params == {
"status": 1,
"patient_name": "",
"start_date": date.today().isoformat(),
"end_date": date.today().isoformat(),
"page_no": 2,
"page_size": 15,
}
def test_remote_new_contracts_use_exact_admin_endpoints_and_dtos() -> None:
"""Prescription, patient and diagnosis methods remain thin endpoint adapters."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
repository.create_prescription(
patient_name="测试患者",
diagnosis_id=5,
herbs=[{"medicine_id": 1, "name": "黄芪", "dosage": 10}],
)
repository.patch_prescription_patient(8, patient_name="修正患者", phone="13800000000", gender=2)
repository.audit_prescription(8, action="reject", remark="剂量不符")
repository.create_prescription_order(
prescription_id=8,
diagnosis_id=5,
recipient_name="修正患者",
recipient_phone="13800000000",
)
repository.list_paid_prescription_orders(5, prescription_order_id=3)
repository.list_medicines(name="黄芪")
repository.patient_orders(page_no=1, page_size=15, fulfillment_status=2)
repository.patient_progress(page_no=1, page_size=15, status=1)
repository.patient_detail(5)
repository.appointment_history(5)
repository.assign_history(5)
repository.assign_patient(5, 20, is_inherit=1)
repository.fill_patient_id_card(5, "410000199001010000")
repository.book_patient_appointment({"diagnosis_id": 5, "appointment_date": "2026-08-10"})
repository.cancel_patient_appointment(7)
repository.update_diagnosis(5, {"clinical_diagnosis": "气虚证"})
repository.list_appointment_rosters(
doctor_id=1,
start_date="2026-08-10",
end_date="2026-08-16",
)
slots = repository.get_available_appointment_slots(
doctor_id=1,
appointment_date="2026-08-10",
)
assert (
"tcm.prescription/patchPatient",
{
"id": 8,
"patient_name": "修正患者",
"phone": "13800000000",
"gender": 2,
},
) in client.post_calls
assert (
"tcm.prescription/audit",
{
"id": 8,
"action": "reject",
"remark": "剂量不符",
},
) in client.post_calls
assert (
"firstvisit.myPatient/assign",
{
"id": 5,
"assistant_id": 20,
"is_inherit": 1,
},
) in client.post_calls
assert ("tcm.diagnosis/edit", {"id": 5, "clinical_diagnosis": "气虚证"}) in client.post_calls
assert slots == {"slots": [{"time": "09:00", "available": True}]}
get_endpoints = {endpoint for endpoint, _ in client.get_calls}
assert {
"tcm.prescriptionOrder/paidPayOrders",
"doctor.medicine/lists",
"firstvisit.myPatient/orders",
"firstvisit.myPatient/progress",
"tcm.diagnosis/readonlyDetail",
"doctor.appointment/lists",
"tcm.diagnosis/assignLogList",
"doctor.roster/lists",
"doctor.appointment/availableSlots",
} <= get_endpoints
@pytest.mark.parametrize(
"unsafe_reference",
[r"C:\records\tongue.jpg", r"\\server\share\report.pdf", "file:///tmp/a.jpg"],
)
def test_remote_note_rejects_local_material_references(unsafe_reference: str) -> None:
"""No drive, UNC or file URI can reach addDoctorNote JSON."""
client = RecordingClient()
repository = RemoteDoctorRepository(client) # type: ignore[arg-type]
with pytest.raises(ValueError, match="server uri/url"):
repository.add_doctor_note(5, tongue_images=[unsafe_reference])
assert not any(
endpoint == "doctor.appointment/addDoctorNote" for endpoint, _payload in client.post_calls
)
def test_remote_dynamic_menu_preserves_json_metadata_but_drops_runtime_objects() -> None:
"""Future menu fields pass through safely without evaluating arbitrary values."""
repository = RemoteDoctorRepository(RecordingClient()) # type: ignore[arg-type]
menu = repository.get_session().menu
assert menu[0]["component"] == "tcm/diagnosis/index"
assert menu[0]["future"] == {"badge": 3}
assert menu[0]["children"][0]["perms"] == "tcm.diagnosis/readonlyDetail"
assert "unsafe" not in menu[0]
assert 7 not in menu[0]
def test_canonical_prescription_permissions_match_routed_views() -> None:
"""Service exports one canonical spelling for each routed action."""
assert PRESCRIPTION_LIBRARY_PERMISSIONS == {
"create": "wcf.prescription/add",
"read": "wcf.prescription/read",
"update": "wcf.prescription/edit",
"delete": "wcf.prescription/delete",
}
assert PRESCRIPTION_PERMISSIONS["delete"] == "cf.prescription/del"
assert PRESCRIPTION_PERMISSIONS["patch_patient"] == "tcm.prescription/patchPatient"
def test_demo_mutates_prescriptions_orders_and_patient_workspaces() -> None:
"""Offline mode supports the full workflow rather than static placeholders."""
repository = DemoDoctorRepository(today=date(2026, 8, 10))
created = repository.create_prescription(
diagnosis_id=501,
patient_name="林晓岚",
phone="13800131203",
gender=2,
herbs=[{"medicine_id": 17, "name": "黄芪", "dosage": 20}],
doctor_name="陈医生(演示)",
doctor_signature="data:image/png;base64,demo",
)
updated = repository.update_prescription(created.id, {"clinical_diagnosis": "气虚证"})
repository.patch_prescription_patient(
created.id, patient_name="林晓岚(修正)", phone="13800131203", gender=2
)
audit = repository.audit_prescription(created.id, action="approve")
order = repository.create_prescription_order(
prescription_id=created.id,
diagnosis_id=501,
recipient_name="林晓岚(修正)",
recipient_phone="13800131203",
amount=268,
)
assert updated.clinical_diagnosis == "气虚证"
assert audit["audit_status"] == 1
assert repository.get_prescription(created.id).has_prescription_order
assert repository.get_prescription_order(order["id"])["prescription_id"] == created.id
assert repository.patient_orders().extend["summary"]["order_count"] == 2
assigned = repository.assign_patient(501, 2002)
repository.fill_patient_id_card(501, "410000199001010000")
appointment = repository.book_patient_appointment(
diagnosis_id=501,
appointment_date="2026-08-11",
appointment_time="15:00-15:30",
)
repository.update_diagnosis(501, {"chief_complaint": "乏力"})
assert assigned["assistant_name"] == "许医助"
assert repository.assign_history(501).total == 2
assert repository.patient_detail(501)["diagnosis"]["chief_complaint"] == "乏力"
assert repository.appointment_history(501).total == 2
repository.cancel_patient_appointment(appointment["id"])
assert repository.appointment_history(501).items[-1].status == 2
assert repository.list_medicines(name="黄芪").items[0]["name"] == "黄芪"
def test_reject_actions_require_a_remark() -> None:
"""Audit rejection mirrors the admin dialog's mandatory reason boundary."""
repository = DemoDoctorRepository(today=date(2026, 8, 10))
with pytest.raises(ValueError, match="remark"):
repository.audit_prescription(802, action="reject")
with pytest.raises(ValueError, match="remark"):
repository.audit_patient_order_payment(901, "reject")
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QApplication
from doctor_workstation import app as app_module
from doctor_workstation.app import ApplicationController
from doctor_workstation.core.errors import AuthenticationExpiredError
from doctor_workstation.services import DemoDoctorRepository
from doctor_workstation.ui import login as login_module
from doctor_workstation.ui import widgets as widget_module
from doctor_workstation.ui.login import LoginWindow
from doctor_workstation.ui.pages.consultations import _video_payload
from doctor_workstation.ui.shell import NAVIGATION
from doctor_workstation.ui.widgets import (
gender_text,
invoke,
set_authentication_expired_handler,
)
def test_consultation_video_payload_keeps_appointment_and_diagnosis_ids_distinct() -> None:
record = SimpleNamespace(
id=501,
appointment_id=101,
patient_id=301,
patient_name="林晓岚",
)
payload = _video_payload(record)
assert payload["appointment_id"] == 101
assert payload["diagnosis_id"] == 501
assert payload["patient_id"] == 301
def test_gender_text_maps_legacy_codes_and_preserves_labels() -> None:
assert gender_text(1) == ""
assert gender_text("2") == ""
assert gender_text(0) == "未知"
assert gender_text("未知标签") == "未知标签"
def test_navigation_requires_each_pages_actual_list_capability() -> None:
assert {item.key: item.permissions for item in NAVIGATION} == {
"reception": ("doctor.appointment/lists",),
"prescription_library": ("tcm.prescriptionLibrary/lists",),
"prescriptions": ("tcm.prescription/lists",),
"patients": ("firstvisit.myPatient/lists",),
"consultations": ("tcm.diagnosis/lists",),
}
def test_video_release_does_not_remove_a_newer_call() -> None:
older = object()
newer = object()
controller = SimpleNamespace(video_calls={"501": newer})
ApplicationController._release_video_call(controller, "501", older)
assert controller.video_calls == {"501": newer}
ApplicationController._release_video_call(controller, "501", newer)
assert controller.video_calls == {}
def test_invoke_leaves_prescription_filters_for_repository_mapping() -> None:
"""The UI adapter only normalises pagination, not API-specific DTO fields."""
class Repository:
def list_prescriptions(self, **filters: Any) -> dict[str, Any]:
return filters
assert invoke(
Repository(),
"prescriptions",
keyword="CF-2026-8",
status=2,
page=3,
page_size=15,
) == {
"keyword": "CF-2026-8",
"status": 2,
"page_no": 3,
"page_size": 15,
}
def test_async_error_dispatch_consumes_active_session_expiry_globally() -> None:
"""A consumed authentication expiry does not also reach a stale page."""
global_errors: list[Exception] = []
local_errors: list[Exception] = []
error = AuthenticationExpiredError("expired", code=-1)
try:
set_authentication_expired_handler(lambda caught: global_errors.append(caught) is None)
widget_module._dispatch_async_error(error, local_errors.append)
finally:
set_authentication_expired_handler(None)
assert global_errors == [error]
assert local_errors == []
def test_controller_session_expiry_returns_to_login_once() -> None:
"""The composition root owns the single transition out of an active shell."""
messages: list[str] = []
controller = SimpleNamespace(
shell_window=object(),
current_repository=object(),
_authentication_expiry_in_progress=False,
_logout=lambda *, message="": messages.append(message),
)
error = AuthenticationExpiredError("expired", code=-1)
assert ApplicationController._on_authentication_expired(controller, error)
assert ApplicationController._on_authentication_expired(controller, error)
assert messages == ["登录状态已失效,请重新登录。"]
def test_persisted_session_restore_blocks_manual_submit_before_worker_start(
monkeypatch: Any,
) -> None:
"""Login controls are locked before the asynchronous restore is dispatched."""
pending_states: list[bool] = []
worker_calls: list[tuple[Any, dict[str, Any]]] = []
worker = object()
repository = SimpleNamespace(restore_session=lambda: None)
login_window = SimpleNamespace(set_session_restore_pending=pending_states.append)
controller = SimpleNamespace(
remote_repository=repository,
_shutting_down=False,
config=SimpleNamespace(demo_mode=False),
current_repository=None,
_restore_generation=0,
_restore_in_progress=False,
_restore_worker=None,
login_window=login_window,
_on_restore_success=lambda *args: None,
_on_restore_error=lambda *args: None,
_on_restore_finished=lambda *args: None,
)
def fake_run_async(function: Any, **callbacks: Any) -> object:
assert pending_states == [True]
worker_calls.append((function, callbacks))
return worker
monkeypatch.setattr(app_module, "run_async", fake_run_async)
ApplicationController._begin_session_restore(controller)
assert controller._restore_in_progress
assert controller._restore_worker is worker
assert worker_calls[0][0] == repository.restore_session
def test_login_restore_pending_uses_existing_submit_guard() -> None:
"""A manual submit is a no-op for the whole persisted-token validation window."""
class Banner:
def clear(self) -> None:
pass
class LoginDouble:
def __init__(self) -> None:
self._loading = False
self.error_banner = Banner()
self.loading_options: dict[str, Any] = {}
def _set_loading(self, loading: bool, **options: Any) -> None:
self._loading = loading
self.loading_options = options
login = LoginDouble()
LoginWindow.set_session_restore_pending(login, True) # type: ignore[arg-type]
LoginWindow.submit(login) # type: ignore[arg-type]
assert login._loading
assert login.loading_options["button_text"] == "正在恢复登录…"
def test_real_demo_login_reaches_success_without_widget_adapter(
monkeypatch: Any,
tmp_path: Any,
) -> None:
"""Exercise the actual login widgets and demo repository as one contract."""
application = QApplication.instance() or QApplication([])
repository = DemoDoctorRepository()
settings = QSettings(str(tmp_path / "login.ini"), QSettings.Format.IniFormat)
config = SimpleNamespace(
api_base_url="https://127.0.0.1:9",
request_timeout=30,
demo_mode=True,
remembered_account="",
)
payloads: list[dict[str, Any]] = []
def run_immediately(function: Any, **callbacks: Any) -> object:
try:
callbacks["on_success"](function())
except Exception as error: # pragma: no cover - assertion output is more useful
callbacks["on_error"](error)
finally:
callbacks["on_finished"]()
return object()
monkeypatch.setattr(login_module, "run_async", run_immediately)
window = LoginWindow(
object(),
config=config,
demo_repository=repository,
settings=settings,
)
window.login_succeeded.connect(payloads.append)
window.submit()
assert len(payloads) == 1
assert payloads[0]["repository"] is repository
assert payloads[0]["demo_mode"] is True
assert window.busy_overlay.label.text() == "正在验证账号…"
assert not window._loading
window.close()
application.processEvents()
+308
View File
@@ -0,0 +1,308 @@
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_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) -> None:
start_entered.set()
assert release_start.wait(2)
events.append(("start", diagnosis_id, patient_id, call_type))
def bind_call_room(self, diagnosis_id: int, room_id: str) -> None:
events.append(("bind", diagnosis_id, room_id))
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__))
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
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 events == [
("start", 123, 8, 2),
("bind", 123, "456789"),
("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_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:///")