更新
This commit is contained in:
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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:///")
|
||||
Reference in New Issue
Block a user