更新
This commit is contained in:
@@ -215,21 +215,43 @@ def test_invalid_envelope_raises_protocol_error() -> None:
|
||||
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."""
|
||||
def test_token_store_file_fallback_never_persists_plaintext_password(tmp_path: Path) -> None:
|
||||
"""The fallback may contain a Windows DPAPI blob, but never plaintext."""
|
||||
|
||||
path = tmp_path / "credentials.json"
|
||||
store = TokenStore(path, keyring_backend=None)
|
||||
store.save_token("token-value", account="doctor")
|
||||
password_saved = store.save_password(
|
||||
"must-not-reach-disk",
|
||||
account="doctor",
|
||||
scope="https://example.test/adminapi",
|
||||
)
|
||||
|
||||
assert store.load_token() == "token-value"
|
||||
assert store.load_account() == "doctor"
|
||||
restored = store.load_password(account="doctor", scope="https://example.test/adminapi")
|
||||
if password_saved:
|
||||
assert restored == "must-not-reach-disk"
|
||||
else:
|
||||
assert restored is None
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert payload == {"token": "token-value", "account": "doctor"}
|
||||
assert payload["token"] == "token-value"
|
||||
assert payload["account"] == "doctor"
|
||||
assert "password" not in path.read_text(encoding="utf-8").lower()
|
||||
assert "must-not-reach-disk" not in path.read_text(encoding="utf-8")
|
||||
store.clear_token()
|
||||
assert store.load_token() is None
|
||||
assert store.load_account() == "doctor"
|
||||
after_logout = store.load_password(
|
||||
account="doctor",
|
||||
scope="https://example.test/adminapi",
|
||||
)
|
||||
if password_saved:
|
||||
assert after_logout == "must-not-reach-disk"
|
||||
else:
|
||||
assert after_logout is None
|
||||
store.clear_account()
|
||||
assert store.load_password(account="doctor", scope="https://example.test/adminapi") is None
|
||||
|
||||
|
||||
class _MemoryKeyring:
|
||||
@@ -267,6 +289,33 @@ def test_token_store_prefers_available_keyring(tmp_path: Path) -> None:
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"account": "doctor"}
|
||||
|
||||
|
||||
def test_token_store_keeps_login_password_in_scoped_keyring_only(tmp_path: Path) -> None:
|
||||
backend = _MemoryKeyring()
|
||||
path = tmp_path / "credentials.json"
|
||||
store = TokenStore(path, keyring_backend=backend)
|
||||
scope = "https://example.test/adminapi/"
|
||||
|
||||
assert store.save_password("secret-value", account="doctor", scope=scope)
|
||||
assert (
|
||||
store.load_password(account="doctor", scope="https://example.test/adminapi")
|
||||
== "secret-value"
|
||||
)
|
||||
assert store.load_password(account="doctor", scope="https://other.test/adminapi") is None
|
||||
assert "secret-value" not in path.read_text(encoding="utf-8")
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {
|
||||
"account": "doctor",
|
||||
"scope": "https://example.test/adminapi",
|
||||
}
|
||||
|
||||
next_scope = "https://next.test/adminapi"
|
||||
assert store.save_password("next-secret", account="doctor", scope=next_scope)
|
||||
assert store.load_password(account="doctor", scope=scope) is None
|
||||
assert store.load_password(account="doctor", scope=next_scope) == "next-secret"
|
||||
|
||||
store.clear_password(account="doctor", scope=next_scope)
|
||||
assert store.load_password(account="doctor", scope=next_scope) is None
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
@@ -10,13 +10,20 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from doctor_workstation.core import PermissionSet
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
from doctor_workstation.services.mock_repository import DemoDoctorRepository
|
||||
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
|
||||
from doctor_workstation.ui.pages.reception import (
|
||||
NOTE_LIMIT,
|
||||
QueueRow,
|
||||
ReceptionPage,
|
||||
_is_image_attachment,
|
||||
)
|
||||
from doctor_workstation.ui.widgets import StatusBadge
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -50,6 +57,71 @@ def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(reception_module, "run_async", run_immediately)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "expected"),
|
||||
[
|
||||
("https://cdn.test/tongue.JPG?token=1", True),
|
||||
("https://cdn.test/report.webp", True),
|
||||
("https://cdn.test/report.pdf", False),
|
||||
],
|
||||
)
|
||||
def test_note_attachment_preview_type_is_extension_aware(path: str, expected: bool) -> None:
|
||||
assert _is_image_attachment(path) is expected
|
||||
|
||||
|
||||
def test_note_attachments_offer_image_preview_and_file_open(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
page = ReceptionPage(DemoDoctorRepository(), PermissionSet([]))
|
||||
page._render_notes(
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"note_date": "2026-08-12",
|
||||
"tongue_images": ["https://cdn.test/tongue.jpg"],
|
||||
"report_files": [
|
||||
"https://cdn.test/check.png",
|
||||
"https://cdn.test/check.pdf",
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
labels = [button.text() for button in page.notes_container.findChildren(QPushButton)]
|
||||
assert labels.count("预览") == 2
|
||||
assert labels.count("打开") == 1
|
||||
page.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_queue_status_badge_is_not_clipped_in_narrow_panel(
|
||||
application: QApplication,
|
||||
) -> None:
|
||||
row = QueueRow(
|
||||
{
|
||||
"patient_name": "张蒙",
|
||||
"status": 1,
|
||||
"status_desc": "待接诊",
|
||||
"appointment_time": "12:45:00",
|
||||
"gender": 1,
|
||||
"age": 36,
|
||||
"assistant_name": "苏亚梅",
|
||||
}
|
||||
)
|
||||
row.setFixedWidth(280)
|
||||
row.show()
|
||||
application.processEvents()
|
||||
|
||||
badge = row.findChild(StatusBadge)
|
||||
assert badge is not None
|
||||
assert badge.height() >= 24
|
||||
assert badge.width() >= 54
|
||||
assert badge.geometry().right() < row.width()
|
||||
|
||||
row.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _detail(
|
||||
appointment_id: int,
|
||||
*,
|
||||
@@ -361,6 +433,7 @@ def test_video_payload_keeps_three_identifiers_distinct(
|
||||
"patient_id": 141,
|
||||
"diagnosis_id": 241,
|
||||
"patient_name": "视频患者",
|
||||
"mode": "im",
|
||||
"record": detail["appointment"],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QSettings
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox
|
||||
|
||||
from doctor_workstation import app as app_module
|
||||
from doctor_workstation.app import ApplicationController
|
||||
@@ -232,11 +232,28 @@ def test_real_demo_login_reaches_success_without_widget_adapter(
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||
def test_remembered_password_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings_path = tmp_path / "remember-account.ini"
|
||||
settings_path = tmp_path / "remember-password.ini"
|
||||
secrets: dict[tuple[str, str], str] = {}
|
||||
|
||||
def load_password(*, account: str, scope: str) -> str | None:
|
||||
return secrets.get((account, scope.rstrip("/")))
|
||||
|
||||
def save_password(password: str, *, account: str, scope: str) -> bool:
|
||||
secrets[(account, scope.rstrip("/"))] = password
|
||||
return True
|
||||
|
||||
def clear_password(*, account: str, scope: str) -> None:
|
||||
secrets.pop((account, scope.rstrip("/")), None)
|
||||
|
||||
credentials = SimpleNamespace(
|
||||
load_password=load_password,
|
||||
save_password=save_password,
|
||||
clear_password=clear_password,
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
api_base_url="",
|
||||
api_base_url="https://example.test/adminapi",
|
||||
request_timeout=30,
|
||||
verify_ssl=True,
|
||||
demo_mode=False,
|
||||
@@ -246,8 +263,9 @@ def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||
object(),
|
||||
config=config,
|
||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||
credential_store=credentials,
|
||||
)
|
||||
first._on_login_success({}, "admin", True)
|
||||
first._on_login_success({}, "admin", True, "secret-value")
|
||||
first.close()
|
||||
application.processEvents()
|
||||
|
||||
@@ -255,12 +273,28 @@ def test_remembered_account_survives_a_new_login_window(tmp_path: Any) -> None:
|
||||
object(),
|
||||
config=config,
|
||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||
credential_store=credentials,
|
||||
)
|
||||
assert restored.account_edit.text() == "admin"
|
||||
assert restored.password_edit.text() == "secret-value"
|
||||
assert restored.remember_check.text() == "记住密码"
|
||||
assert restored.remember_check.isChecked()
|
||||
|
||||
restored._on_login_success({}, "admin", False, "secret-value")
|
||||
restored.close()
|
||||
application.processEvents()
|
||||
|
||||
forgotten = LoginWindow(
|
||||
object(),
|
||||
config=config,
|
||||
settings=QSettings(str(settings_path), QSettings.Format.IniFormat),
|
||||
credential_store=credentials,
|
||||
)
|
||||
assert forgotten.password_edit.text() == ""
|
||||
assert not forgotten.remember_check.isChecked()
|
||||
forgotten.close()
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def test_server_settings_panel_keeps_controls_separated_at_minimum_window(
|
||||
tmp_path: Any,
|
||||
@@ -386,6 +420,37 @@ def test_certificate_error_explains_self_signed_server_setting() -> None:
|
||||
assert "服务器设置" in message
|
||||
|
||||
|
||||
def test_qt_standard_dialog_buttons_are_localized_to_chinese() -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
app_module._install_chinese_translations(application)
|
||||
|
||||
question = QMessageBox()
|
||||
question.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel
|
||||
)
|
||||
assert question.button(QMessageBox.StandardButton.Yes).text() == "是"
|
||||
assert question.button(QMessageBox.StandardButton.Cancel).text() == "取消"
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Save
|
||||
| QDialogButtonBox.StandardButton.Close
|
||||
)
|
||||
assert buttons.button(QDialogButtonBox.StandardButton.Ok).text() == "确定"
|
||||
assert buttons.button(QDialogButtonBox.StandardButton.Save).text() == "保存"
|
||||
assert buttons.button(QDialogButtonBox.StandardButton.Close).text() == "关闭"
|
||||
|
||||
|
||||
def test_friendly_error_hides_english_technical_messages() -> None:
|
||||
message = friendly_error(
|
||||
TypeError("invoke() takes 2 positional arguments but 3 were given")
|
||||
)
|
||||
assert message == "程序执行失败,请重试;若问题持续出现,请联系管理员。"
|
||||
assert friendly_error(RuntimeError("API response envelope must be an object")) == (
|
||||
"服务器返回的数据格式不正确,请联系管理员检查接口。"
|
||||
)
|
||||
|
||||
|
||||
def test_certificate_error_opens_server_settings(tmp_path: Any) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
settings = QSettings(str(tmp_path / "certificate-error.ini"), QSettings.Format.IniFormat)
|
||||
|
||||
@@ -274,6 +274,57 @@ def test_failed_start_prevents_bind_and_end_writes() -> None:
|
||||
assert events == ["start"]
|
||||
|
||||
|
||||
def test_video_screenshot_is_uploaded_and_appended_to_patient_tongue_images() -> None:
|
||||
events: list[tuple[object, ...]] = []
|
||||
|
||||
class Repository:
|
||||
def start_call(self, diagnosis_id: int, *, call_type: int) -> None:
|
||||
events.append(("start", diagnosis_id, call_type))
|
||||
|
||||
def upload_material_bytes(
|
||||
self,
|
||||
content: bytes,
|
||||
filename: str,
|
||||
material_type: str,
|
||||
cid: int = 0,
|
||||
) -> str:
|
||||
events.append(("upload", content, filename, material_type, cid))
|
||||
return "/uploads/image/callshot-123.jpg"
|
||||
|
||||
def add_doctor_note(
|
||||
self,
|
||||
diagnosis_id: int,
|
||||
content: str,
|
||||
tongue_images: list[str],
|
||||
) -> None:
|
||||
events.append(("note", diagnosis_id, content, tongue_images))
|
||||
|
||||
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,
|
||||
)
|
||||
lifecycle = OrderedCallLifecycle(request, Repository(), logging.getLogger(__name__))
|
||||
|
||||
lifecycle.start()
|
||||
screenshot = lifecycle.save_screenshot(b"jpeg-frame", "callshot-123.jpg")
|
||||
lifecycle.end("test")
|
||||
|
||||
assert screenshot.result(timeout=2) == "/uploads/image/callshot-123.jpg"
|
||||
assert lifecycle.wait(1) is True
|
||||
assert events == [
|
||||
("start", 123, 2),
|
||||
("upload", b"jpeg-frame", "callshot-123.jpg", "image", 0),
|
||||
("note", 123, "", ["/uploads/image/callshot-123.jpg"]),
|
||||
("end", 123),
|
||||
]
|
||||
|
||||
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user