Files
xieyi-weixin-/test_patient_session_errors.py
T
2026-09-30 19:30:58 +08:00

492 lines
25 KiB
Python

# -*- coding: utf-8 -*-
"""患者查询错误分类、ZYT 会话保活节流与绑定窗口提示;只用合成 HTTP 和内存配置。
源码运行时 application_data_dir() 就是源码目录,请在临时副本里运行本测试。
"""
import copy
import io
import json
import os
import time
import unittest
import urllib.error
from unittest import mock
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import backend_client as client # noqa: E402
ZYT_SESSION_PATH = "/adminapi/desktop/session"
class PatientRequestErrorMapping(unittest.TestCase):
def request_error(self, status, body, path=None):
error = urllib.error.HTTPError(
"https://synthetic.invalid/path", status, "synthetic", {},
io.BytesIO(json.dumps(body, ensure_ascii=False).encode("utf-8")))
with mock.patch.object(client.urllib.request, "urlopen", side_effect=error):
try:
client._request("GET", "https://synthetic.invalid", path or client.DESKTOP_PATIENT_SEARCH_PATH)
except client.BackendError as exc:
return exc
self.fail("expected synthetic HTTP error")
def test_ip_restricted_is_query_rejected_not_expiry_or_logout(self):
for path in (client.DESKTOP_PATIENT_SEARCH_PATH + "?keyword=synthetic", client.DESKTOP_PATIENT_BINDING_PATH,
client.DESKTOP_PATIENT_CONTEXT_PATH, client.DESKTOP_PATIENT_ORDER_SUMMARY_PATH):
with self.subTest(path=path):
error = self.request_error(
409, {"detail": {"code": "zyt_ip_restricted", "message": "synthetic ip rejected"}}, path)
self.assertIsInstance(error, client.PatientQueryRejected)
self.assertNotIsInstance(error, client.PatientSessionExpired)
self.assertNotIsInstance(error, client.AuthenticationError)
self.assertEqual(error.code, "zyt_ip_restricted")
self.assertEqual(error.status, 409)
self.assertEqual(str(error), "synthetic ip rejected")
def test_ip_restricted_without_message_uses_readable_default(self):
error = self.request_error(409, {"detail": {"code": "zyt_ip_restricted"}})
self.assertIsInstance(error, client.PatientQueryRejected)
self.assertNotIn("HTTP 409", str(error))
self.assertIn("登录 IP 校验", str(error))
self.assertNotIn("过期", str(error))
def test_order_routes_show_staff_message_instead_of_model_text(self):
# 订单接口的 message 是给模型的中性口径,客服界面要显示 staff_message 里的说明。
for code, kind in (("zyt_ip_restricted", client.PatientQueryRejected),
("zyt_session_expired", client.PatientSessionExpired)):
with self.subTest(code=code):
error = self.request_error(409, {"detail": {
"code": code, "message": "synthetic neutral", "staff_message": "synthetic staff"}},
client.DESKTOP_PATIENT_ORDER_SUMMARY_PATH)
self.assertIsInstance(error, kind)
self.assertEqual(str(error), "synthetic staff")
def test_session_expired_mapping_unchanged(self):
error = self.request_error(409, {"detail": {"code": "zyt_session_expired", "message": "synthetic expired"}})
self.assertIsInstance(error, client.PatientSessionExpired)
self.assertNotIsInstance(error, client.PatientQueryRejected)
self.assertEqual(error.code, "zyt_session_expired")
self.assertEqual(str(error), "synthetic expired")
def test_legacy_expiry_text_still_expired(self):
error = self.request_error(502, {"detail": "登录超时,请重新登录"})
self.assertIsInstance(error, client.PatientSessionExpired)
def test_patient_permission_denied_stays_generic_backend_error(self):
error = self.request_error(403, {"detail": {"code": "permission_denied", "message": "synthetic denied"}})
self.assertEqual(type(error), client.BackendError)
self.assertEqual(error.code, "permission_denied")
self.assertEqual(error.status, 403)
def test_desktop_revocation_elsewhere_stays_authentication_error(self):
for status in (401, 403):
with self.subTest(status=status):
error = self.request_error(status, {"detail": "synthetic revoked"}, client.DESKTOP_ME_PATH)
self.assertIsInstance(error, client.AuthenticationError)
self.assertNotIsInstance(error, client.PatientQueryRejected)
self.assertIsInstance(self.request_error(401, {"detail": "synthetic revoked"}), client.AuthenticationError)
class PatientKeepaliveThrottle(unittest.TestCase):
def setUp(self):
self.store = {
"server_url": "https://synthetic.invalid", "zyt_api_url": "https://identity.invalid",
"zyt_account": "synthetic-admin", "desktop_token_protected": "sealed:desktop-original",
"zyt_token_protected": "sealed:zyt-old", "desktop_expires_at": 5000,
"desktop_account": {"id": 7}, "last_error": "",
}
def save(settings):
self.store = copy.deepcopy(settings)
self.enterContext(mock.patch.object(client, "load_settings", side_effect=lambda: copy.deepcopy(self.store)))
self.enterContext(mock.patch.object(client, "save_settings", side_effect=save))
self.enterContext(mock.patch.object(client, "_protect_secret", side_effect=lambda value: "sealed:" + value))
self.enterContext(mock.patch.object(
client, "_unprotect_secret", side_effect=lambda value: str(value or "").removeprefix("sealed:")))
self.enterContext(mock.patch.object(client, "is_frozen", return_value=False))
self.enterContext(mock.patch.object(
client.urllib.request, "urlopen", side_effect=AssertionError("real network forbidden")))
self.request = self.enterContext(mock.patch.object(client, "_request", side_effect=self.response))
self.login = self.enterContext(mock.patch.object(client, "_zyt_login", side_effect=AssertionError("login forbidden")))
self.exchange = self.enterContext(mock.patch.object(
client, "_exchange_desktop_token", side_effect=AssertionError("exchange forbidden")))
self.logout = self.enterContext(mock.patch.object(client, "desktop_logout", side_effect=AssertionError("logout forbidden")))
self.clock = [1000.0]
self.enterContext(mock.patch.object(client.time, "monotonic", side_effect=lambda: self.clock[0]))
self.enterContext(mock.patch.object(client.time, "time", return_value=1000))
original = dict(client._PATIENT_KEEPALIVE_LAST)
client._PATIENT_KEEPALIVE_LAST.clear()
self.addCleanup(lambda: (client._PATIENT_KEEPALIVE_LAST.clear(), client._PATIENT_KEEPALIVE_LAST.update(original)))
self.me_failure = None
self.zyt_failure = None
self.zyt_result = {"code": 1, "data": {"status": "active"}}
self.seen_before_zyt_call = []
def response(self, method, server, path, **kwargs):
if path == client.DESKTOP_ME_PATH:
if self.me_failure:
raise self.me_failure
return 200, {"id": 7, "expires_at": 9000}
if path == ZYT_SESSION_PATH:
self.seen_before_zyt_call.append(dict(client._PATIENT_KEEPALIVE_LAST))
if self.zyt_failure:
raise self.zyt_failure
return 200, copy.deepcopy(self.zyt_result)
raise AssertionError("unexpected synthetic route: " + path)
def zyt_calls(self):
return sum(call.args[2] == ZYT_SESSION_PATH for call in self.request.call_args_list)
def advance(self, seconds):
self.clock[0] += seconds
def test_interval_is_shorter_than_the_thirty_minute_gui_timer(self):
self.assertLess(client.PATIENT_KEEPALIVE_INTERVAL_SECONDS, 30 * 60)
self.assertLess(client.PATIENT_KEEPALIVE_RETRY_SECONDS, client.PATIENT_KEEPALIVE_INTERVAL_SECONDS)
def test_every_thirty_minute_tick_really_calls_zyt(self):
for _ in range(4):
self.assertEqual(client.keepalive_zyt_session(), {"status": "active"})
self.advance(30 * 60)
self.assertEqual(self.zyt_calls(), 4)
self.login.assert_not_called()
self.exchange.assert_not_called()
self.logout.assert_not_called()
self.assertEqual(self.store["zyt_token_protected"], "sealed:zyt-old")
def test_extra_trigger_inside_interval_is_throttled(self):
self.assertEqual(client.keepalive_zyt_session()["status"], "active")
self.advance(client.PATIENT_KEEPALIVE_INTERVAL_SECONDS - 1)
self.assertEqual(client.keepalive_zyt_session(), {"status": "skipped", "reason": "throttled"})
self.advance(1)
self.assertEqual(client.keepalive_zyt_session()["status"], "active")
self.assertEqual(self.zyt_calls(), 2)
def test_throttle_is_recorded_only_after_the_call_finishes(self):
client.keepalive_zyt_session()
self.assertEqual(self.seen_before_zyt_call, [{}])
self.assertEqual(client._PATIENT_KEEPALIVE_LAST["wait"], client.PATIENT_KEEPALIVE_INTERVAL_SECONDS)
def test_zyt_network_failure_allows_retry_after_five_minutes(self):
self.zyt_failure = client.BackendError("synthetic offline")
self.assertEqual(client.keepalive_zyt_session()["status"], "unreachable")
self.advance(client.PATIENT_KEEPALIVE_RETRY_SECONDS - 1)
self.assertEqual(client.keepalive_zyt_session()["reason"], "throttled")
self.advance(1)
self.zyt_failure = None
self.assertEqual(client.keepalive_zyt_session()["status"], "active")
self.assertEqual(self.zyt_calls(), 2)
self.advance(client.PATIENT_KEEPALIVE_RETRY_SECONDS)
self.assertEqual(client.keepalive_zyt_session()["reason"], "throttled")
def test_unclear_zyt_answer_is_retried_soon(self):
self.zyt_result = {"code": 0, "msg": "synthetic unknown"}
self.assertEqual(client.keepalive_zyt_session()["status"], "unreachable")
self.assertEqual(client._PATIENT_KEEPALIVE_LAST["wait"], client.PATIENT_KEEPALIVE_RETRY_SECONDS)
def test_backend_unreachable_before_zyt_allows_retry_after_five_minutes(self):
self.me_failure = client.BackendError("synthetic backend offline")
self.assertEqual(client.keepalive_zyt_session()["status"], "unreachable")
self.assertEqual(self.zyt_calls(), 0)
self.advance(client.PATIENT_KEEPALIVE_RETRY_SECONDS - 1)
self.assertEqual(client.keepalive_zyt_session()["reason"], "throttled")
self.advance(1)
self.me_failure = None
self.assertEqual(client.keepalive_zyt_session()["status"], "active")
def test_definite_zyt_answers_use_full_interval(self):
cases = [({"code": -1, "msg": "登录超时,请重新登录"}, None, "expired"),
(None, client.AuthenticationError("synthetic denied", status=403), "forbidden"),
(None, client.BackendError("synthetic not deployed", status=404), "unsupported")]
for answer, failure, expected in cases:
with self.subTest(expected=expected):
client._PATIENT_KEEPALIVE_LAST.clear()
self.zyt_result, self.zyt_failure = answer, failure
self.assertEqual(client.keepalive_zyt_session()["status"], expected)
self.advance(client.PATIENT_KEEPALIVE_RETRY_SECONDS)
self.assertEqual(client.keepalive_zyt_session()["reason"], "throttled")
self.logout.assert_not_called()
self.login.assert_not_called()
def test_desktop_invalid_does_not_consume_the_interval(self):
self.me_failure = client.AuthenticationError("synthetic revoked", status=401)
self.assertEqual(client.keepalive_zyt_session()["status"], "desktop_invalid")
self.me_failure = None
self.assertEqual(client.keepalive_zyt_session()["status"], "active")
self.logout.assert_not_called()
def test_new_patient_token_is_not_throttled_by_old_one(self):
self.assertEqual(client.keepalive_zyt_session()["status"], "active")
self.store["zyt_token_protected"] = "sealed:zyt-new"
self.assertEqual(client.keepalive_zyt_session()["status"], "active")
self.assertEqual(self.zyt_calls(), 2)
def test_session_change_during_call_is_skipped_and_not_recorded(self):
def response(method, server, path, **kwargs):
if path == ZYT_SESSION_PATH:
self.store["zyt_token_protected"] = "sealed:zyt-concurrently-new"
return 200, {"code": -1, "msg": "登录超时,请重新登录"}
return self.response(method, server, path, **kwargs)
self.request.side_effect = response
self.assertEqual(client.keepalive_zyt_session(), {"status": "skipped", "reason": "session_changed"})
self.assertEqual(client._PATIENT_KEEPALIVE_LAST, {})
self.assertEqual(self.store["zyt_token_protected"], "sealed:zyt-concurrently-new")
try:
from PySide6.QtWidgets import QApplication
import wechat_gui_qt as gui
except Exception as exc: # pragma: no cover - 没有 Qt 运行库时只跑上面的用例
gui = None
GUI_IMPORT_ERROR = repr(exc)
else:
GUI_IMPORT_ERROR = ""
def _context():
return {
"permissions": {"can_search": True, "can_bind": True, "can_unbind": True},
"conversation": {"name": "synthetic chat"},
"person": {"id": "person-1"},
"people": [{"id": "person-1", "display_name": "synthetic person", "patient_bindings": []}],
}
@unittest.skipIf(gui is None, f"PySide6 GUI unavailable: {GUI_IMPORT_ERROR}")
class PatientBindingDialogStates(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def setUp(self):
self.dialog = gui.PatientBindingDialog(_context(), "account-1", "conversation-1")
self.addCleanup(self.dialog.deleteLater)
self.dialog.search_input.setText("synthetic keyword")
def button_shown(self):
return not self.dialog.reauthenticate_button.isHidden()
def wait_idle(self, seconds=5.0):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
self.app.processEvents()
if not self.dialog._busy:
return
time.sleep(0.01)
self.fail("dialog stayed busy")
def search_through_worker(self, failure):
with mock.patch.object(client, "search_desktop_patients", side_effect=failure):
self.dialog._search()
self.wait_idle()
def test_expired_offers_reverification(self):
self.search_through_worker(client.PatientSessionExpired(status=409))
self.assertTrue(self.button_shown())
self.assertIn("登录已过期", self.dialog.hint_label.text())
self.assertFalse(self.dialog.search_button.isEnabled())
self.assertFalse(self.dialog.bind_button.isEnabled())
def test_rejected_shows_backend_message_without_reverification(self):
message = "患者信息暂时查不到:synthetic IP 校验说明"
self.search_through_worker(client.PatientQueryRejected(message, status=409))
self.assertFalse(self.button_shown())
self.assertEqual(self.dialog.hint_label.text(), message)
self.assertNotIn("登录已过期", self.dialog.hint_label.text())
self.assertTrue(self.dialog.search_button.isEnabled())
def test_rejected_without_message_uses_fallback(self):
self.dialog._search_finished({"ok": False, "error": "", "patient_query_rejected": True})
self.assertIn("重新验证无法解决", self.dialog.hint_label.text())
self.assertFalse(self.button_shown())
def test_bind_rejected_is_not_expiry(self):
self.dialog._operation_finished({"ok": False, "action": "bind", "error": "synthetic rejected",
"patient_session_expired": False, "patient_query_rejected": True})
self.assertEqual(self.dialog.hint_label.text(), "synthetic rejected")
self.assertFalse(self.button_shown())
self.assertFalse(self.dialog.changed)
def test_expired_again_after_reverification_does_not_loop(self):
self.search_through_worker(client.PatientSessionExpired(status=409))
self.assertTrue(self.button_shown())
with mock.patch.object(client, "search_desktop_patients",
side_effect=client.PatientSessionExpired(status=409)) as search:
self.dialog._reauthentication_finished({"ok": True, "context": _context()})
self.wait_idle()
search.assert_called_once_with("synthetic keyword")
text = self.dialog.hint_label.text()
self.assertIn("已重新验证 ZYT 登录", text)
self.assertIn("仍被 ZYT 拒绝", text)
self.assertNotIn("登录已过期", text)
self.assertFalse(self.button_shown())
# 不把人困住:搜索仍可用,稍后可以直接重试。
self.assertTrue(self.dialog.search_button.isEnabled())
self.assertFalse(self.dialog._patient_session_expired)
def test_successful_search_after_reverification(self):
self.search_through_worker(client.PatientSessionExpired(status=409))
result = {"items": [{"patient_id": 9, "patient_name": "synthetic patient"}], "total": 1}
with mock.patch.object(client, "search_desktop_patients", return_value=result):
self.dialog._reauthentication_finished({"ok": True, "context": _context()})
self.wait_idle()
self.assertEqual(self.dialog.result_table.rowCount(), 1)
self.assertIn("找到 1 名患者", self.dialog.hint_label.text())
self.assertFalse(self.button_shown())
def test_failed_password_keeps_reverification_available(self):
self.search_through_worker(client.PatientSessionExpired(status=409))
self.dialog._reauthentication_finished({"ok": False, "error": "synthetic wrong password", "verified": False})
self.assertIn("验证失败", self.dialog.hint_label.text())
self.assertTrue(self.button_shown())
self.assertTrue(self.dialog.reauthenticate_button.isEnabled())
def test_verified_but_context_rejected_is_not_reported_as_failed_verification(self):
self.search_through_worker(client.PatientSessionExpired(status=409))
self.dialog._reauthentication_finished({"ok": False, "verified": True, "error": "synthetic ip rejected",
"patient_query_rejected": True})
self.assertEqual(self.dialog.hint_label.text(), "synthetic ip rejected")
self.assertFalse(self.button_shown())
self.assertTrue(self.dialog.search_button.isEnabled())
def test_verified_but_context_expired_shows_still_rejected(self):
self.search_through_worker(client.PatientSessionExpired(status=409))
self.dialog._reauthentication_finished({"ok": False, "verified": True, "error": "synthetic expired",
"patient_session_expired": True})
self.assertIn("仍被 ZYT 拒绝", self.dialog.hint_label.text())
self.assertNotIn("验证失败", self.dialog.hint_label.text())
self.assertFalse(self.button_shown())
def reauthenticate_through_worker(self, context_failure):
"""点“重新验证”走真实的工作线程和信号;只替换密码框和两次后台调用。"""
self.search_through_worker(client.PatientSessionExpired(status=409))
self.assertTrue(self.button_shown())
with mock.patch.object(gui.QInputDialog, "getText", return_value=("synthetic password", True)), \
mock.patch.object(client, "load_settings", return_value={"zyt_account": "synthetic-admin"}), \
mock.patch.object(client, "reauthenticate_patient_session", return_value={}) as reauthenticate, \
mock.patch.object(client, "desktop_patient_context", side_effect=context_failure) as context:
self.dialog._reauthenticate()
self.assertTrue(self.dialog._busy)
self.wait_idle()
reauthenticate.assert_called_once_with("synthetic password")
context.assert_called_once_with("account-1", "conversation-1", person_id="person-1")
def test_reauthenticate_worker_context_still_expired_stops_the_loop(self):
self.reauthenticate_through_worker(client.PatientSessionExpired(status=409))
text = self.dialog.hint_label.text()
self.assertIn("已重新验证 ZYT 登录", text)
self.assertIn("仍被 ZYT 拒绝", text)
self.assertNotIn("验证失败", text)
self.assertNotIn("登录已过期", text)
self.assertFalse(self.button_shown())
self.assertTrue(self.dialog.search_button.isEnabled())
self.assertTrue(self.dialog._reauthenticated)
self.assertFalse(self.dialog._patient_session_expired)
def test_reauthenticate_worker_context_rejected_shows_backend_message(self):
message = "患者信息暂时查不到:synthetic IP 校验说明"
self.reauthenticate_through_worker(client.PatientQueryRejected(message, status=409))
self.assertEqual(self.dialog.hint_label.text(), message)
self.assertFalse(self.button_shown())
self.assertTrue(self.dialog.search_button.isEnabled())
self.assertTrue(self.dialog._reauthenticated)
self.assertFalse(self.dialog._patient_session_expired)
class _KeepaliveHost:
"""只带 _patient_session_checked 用到的状态。"""
def __init__(self):
from PySide6.QtCore import QTimer
self._patient_keepalive_running = True
self._patient_session_state = "unknown"
self.patient_session_timer = QTimer()
self.patient_session_timer.setInterval(30 * 60 * 1000)
self.patient_session_timer.start()
self.patient_session_retry_timer = QTimer()
self.patient_session_retry_timer.setSingleShot(True)
self.patient_session_retry_timer.setInterval(6 * 60 * 1000)
self.logs = []
def append_log(self, text, tag=""):
self.logs.append((text, tag))
def checked(self, result):
gui.MainWindow._patient_session_checked(self, result)
@unittest.skipIf(gui is None, f"PySide6 GUI unavailable: {GUI_IMPORT_ERROR}")
class PatientKeepaliveLogging(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.app = QApplication.instance() or QApplication([])
def test_unreachable_logs_once_and_schedules_one_retry(self):
host = _KeepaliveHost()
host.checked({"status": "unreachable", "message": "synthetic offline"})
host.checked({"status": "unreachable", "message": "synthetic offline"})
host.checked({"status": "unreachable"})
self.assertEqual(len(host.logs), 1)
self.assertIn("synthetic offline", host.logs[0][0])
self.assertEqual(host.logs[0][1], "warn")
self.assertTrue(host.patient_session_retry_timer.isActive())
self.assertFalse(host._patient_keepalive_running)
def test_periodic_failure_while_retry_pending_restarts_the_retry(self):
# 补查还在等时周期检查又失败:旧补查会落进这次失败后的 5 分钟节流里被跳过,
# 所以必须从这次失败重新计时。
host = _KeepaliveHost()
host.checked({"status": "unreachable", "message": "synthetic offline"})
host.patient_session_retry_timer = mock.Mock()
host.patient_session_retry_timer.isActive.return_value = True
host.checked({"status": "unreachable", "message": "synthetic offline"})
host.patient_session_retry_timer.start.assert_called_once_with()
host.patient_session_retry_timer.stop.assert_not_called()
self.assertEqual(len(host.logs), 1)
def test_recovery_stops_retry_restarts_period_and_logs_once(self):
host = _KeepaliveHost()
host.patient_session_timer = mock.Mock()
host.patient_session_timer.isActive.return_value = True
host.checked({"status": "unreachable", "message": "synthetic offline"})
host.patient_session_timer.start.assert_not_called()
host.checked({"status": "active"})
self.assertFalse(host.patient_session_retry_timer.isActive())
# 从这次真实请求重新计 30 分钟,下一次到点不会被 25 分钟节流挡掉。
host.patient_session_timer.start.assert_called_once_with()
host.checked({"status": "active"})
self.assertEqual([tag for _, tag in host.logs], ["warn", "ok"])
self.assertIn("恢复正常", host.logs[1][0])
def test_forbidden_and_unsupported_log_on_change_only(self):
host = _KeepaliveHost()
for status in ("forbidden", "forbidden", "unsupported", "unsupported", "active", "active"):
host.checked({"status": status, "message": f"synthetic {status}"})
self.assertEqual(len(host.logs), 3)
self.assertIn("synthetic forbidden", host.logs[0][0])
self.assertIn("synthetic unsupported", host.logs[1][0])
self.assertFalse(host.patient_session_retry_timer.isActive())
def test_first_active_and_skipped_are_silent(self):
host = _KeepaliveHost()
host.checked({"status": "skipped", "reason": "throttled"})
host.checked({"status": "active"})
self.assertEqual(host.logs, [])
self.assertEqual(host._patient_session_state, "active")
def test_expired_still_logged_once(self):
host = _KeepaliveHost()
host.checked({"status": "expired"})
host.checked({"status": "expired"})
host.checked({"status": "active"})
self.assertEqual([tag for _, tag in host.logs], ["warn", "ok"])
self.assertIn("已恢复", host.logs[1][0])
if __name__ == "__main__":
unittest.main()