This commit is contained in:
Your Name
2026-08-12 11:03:28 +08:00
parent 09d3fcbf82
commit bc9ad1d3cd
32 changed files with 4586 additions and 2341 deletions
+52 -3
View File
@@ -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."""
+75 -2
View File
@@ -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"],
}
]
+70 -5
View File
@@ -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)
+51
View File
@@ -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",