Files
zyt/app/tests/test_prescription_security_ui.py
T
2026-08-18 14:08:38 +08:00

582 lines
20 KiB
Python

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 (
DiagnosisDetailDialog,
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",
"diagnosis_type": "first_visit",
"local_hospital_name": "杭州市第一人民医院",
"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_prescription_edit_uses_admin_edit_permission(
application: QApplication,
) -> None:
repository = SimpleNamespace(
create_prescription=lambda **_kwargs: {},
update_prescription=lambda **_kwargs: {},
)
create_only = DiagnosisDialog(
repository,
permissions=PermissionSet(["tcm.diagnosis/chufang"]),
)
assert create_only._can_prescribe
assert not create_only._can_edit_prescription
create_only.close()
edit_only = DiagnosisDialog(
repository,
permissions=PermissionSet(["cf.prescription/edit"]),
)
assert not edit_only._can_prescribe
assert edit_only._can_edit_prescription
edit_only.close()
application.processEvents()
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_match_admin_warning_for_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)
assert "存在重复药名" in editor.herb_summary.text()
editor.accept()
assert editor.result() == QDialog.DialogCode.Accepted
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 test_diagnosis_order_detail_lookup_is_queued_before_repository_call(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[tuple[Any, dict[str, Any]]] = []
requested: list[int] = []
shown: list[tuple[int, str]] = []
class Repository:
def get_prescription_order(self, order_id: int) -> dict[str, Any]:
requested.append(order_id)
return {"id": order_id, "order_no": f"DETAIL-{order_id}"}
def queue_async(function: Any, **options: Any) -> object:
queued.append((function, options))
return object()
def present_order_detail(
_host: Any,
order: dict[str, Any],
*,
order_id: int,
permissions: Any,
exec_: bool,
) -> None:
del permissions, exec_
shown.append((order_id, order["order_no"]))
monkeypatch.setattr(dialog_module, "run_async", queue_async)
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
dialog = DiagnosisDetailDialog(
{"orders": [{"id": 17, "order_no": "ROW-17"}]},
repository=Repository(),
)
table = dialog._order_detail_table
button = dialog._order_detail_button
assert table is not None
assert button is not None
table.setCurrentCell(0, 0)
button.click()
assert len(queued) == 1
assert requested == []
assert shown == []
assert not table.isEnabled()
assert not button.isEnabled()
function, options = queued[0]
options["on_success"](function())
options["on_finished"]()
assert requested == [17]
assert shown == [(17, "DETAIL-17")]
assert table.isEnabled()
assert button.isEnabled()
dialog.close()
application.processEvents()
def test_diagnosis_order_detail_ignores_stale_result_and_keeps_row_fallback(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
queued: list[dict[str, Any]] = []
shown: list[tuple[int, str]] = []
def queue_async(_function: Any, **options: Any) -> object:
queued.append(options)
return object()
def present_order_detail(
_host: Any,
order: dict[str, Any],
*,
order_id: int,
permissions: Any,
exec_: bool,
) -> None:
del permissions, exec_
shown.append((order_id, order["order_no"]))
repository = SimpleNamespace(get_prescription_order=lambda order_id: {"id": order_id})
monkeypatch.setattr(dialog_module, "run_async", queue_async)
monkeypatch.setattr(diagnosis_module, "present_order_detail", present_order_detail)
dialog = DiagnosisDetailDialog(
{
"orders": [
{"id": 21, "order_no": "ROW-21"},
{"id": 22, "order_no": "ROW-22"},
]
},
repository=repository,
)
table = dialog._order_detail_table
button = dialog._order_detail_button
assert table is not None
assert button is not None
table.setCurrentCell(0, 0)
dialog._open_selected_order()
table.setCurrentCell(1, 0)
dialog._open_selected_order()
assert len(queued) == 2
queued[0]["on_success"]({"id": 21, "order_no": "STALE-21"})
queued[0]["on_finished"]()
assert shown == []
assert not table.isEnabled()
assert not button.isEnabled()
queued[1]["on_error"](RuntimeError("detail unavailable"))
queued[1]["on_finished"]()
assert shown == [(22, "ROW-22")]
assert table.isEnabled()
assert button.isEnabled()
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, **_kwargs: 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()
def test_diagnosis_prescription_view_fetches_full_detail_and_ignores_stale_row(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
requested: list[int] = []
callbacks: list[tuple[Any, dict[str, Any]]] = []
shown: list[int] = []
class Repository:
def get_prescription(self, prescription_id: int) -> dict[str, Any]:
requested.append(prescription_id)
return {
"id": prescription_id,
"patient_name": f"患者{prescription_id}",
"doctor_signature": "data:image/png;base64,detail",
"herbs": [{"medicine_id": 31, "name": "黄芪", "dosage": 15}],
}
def queue_async(function: Any, **options: Any) -> object:
callbacks.append((function, options))
return object()
class FakePrescriptionDetailDialog:
def __init__(self, detail: dict[str, Any], **_kwargs: Any) -> None:
assert detail["doctor_signature"].endswith("detail")
assert detail["herbs"][0]["name"] == "黄芪"
shown.append(detail["id"])
def exec(self) -> int:
return 0
monkeypatch.setattr(diagnosis_module, "run_async", queue_async)
monkeypatch.setattr(dialog_module, "PrescriptionDetailDialog", FakePrescriptionDetailDialog)
dialog = DiagnosisDialog(Repository(), permissions=PermissionSet(["*"]))
dialog._view_prescription({"id": 11, "herbs": []})
dialog._view_prescription({"id": 12, "herbs": []})
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 requested == [11, 12]
assert shown == [12]
dialog.close()
application.processEvents()