"""Isolated patient-auth UI regressions; no live patient lookups or messages.""" from unittest import TestCase, mock from types import SimpleNamespace from PySide6.QtWidgets import QApplication, QDialog import backend_client as backend import wechat_gui_qt as qt CONTEXT = {'permissions': {'can_search': True, 'can_bind': True, 'can_unbind': True}, 'person': {'id': 'person-test', 'display_name': '测试联系人'}, 'people': [{'id': 'person-test', 'display_name': '测试联系人'}]} RESULT = {'items': [{'patient_id': 42, 'patient_name': '测试患者', 'phone_masked': '13****01'}], 'total': 1} class ImmediateThread: def __init__(self, *, target, **kwargs): self.target = target def start(self): self.target() class PatientSessionDialogTest(TestCase): @classmethod def setUpClass(cls): cls.app = QApplication.instance() or QApplication([]) def setUp(self): self.dialog = qt.PatientBindingDialog(CONTEXT, 'account-test', 'conversation-test') self.addCleanup(self.dialog.deleteLater) self.addCleanup(self.dialog.reject) self.dialog.show() self.dialog.search_input.setText('测试') self.app.processEvents() patcher = mock.patch.object(qt.threading, 'Thread', ImmediateThread) patcher.start(); self.addCleanup(patcher.stop) def expire(self): with mock.patch.object(backend, 'search_desktop_patients', side_effect=backend.PatientSessionExpired('会话过期')): self.dialog.search_button.click() self.app.processEvents() def test_expired_search_offers_reauth_clears_stale_results_and_blocks_binding(self): self.dialog._search_finished({'ok': True, 'result': RESULT}) self.assertEqual(self.dialog.result_table.rowCount(), 1) self.expire() self.assertTrue(self.dialog.reauthenticate_button.isVisible()) self.assertFalse(self.dialog.bind_button.isEnabled()) self.assertFalse(self.dialog.search_button.isEnabled()) self.assertEqual(self.dialog.result_table.rowCount(), 0) self.assertIn('客服监听继续运行', self.dialog.hint_label.text()) self.assertFalse(self.dialog.changed) def test_network_error_does_not_request_reauthentication(self): with mock.patch.object(backend, 'search_desktop_patients', side_effect=backend.BackendError('连接超时')): self.dialog.search_button.click() self.assertFalse(self.dialog.reauthenticate_button.isVisible()) self.assertTrue(self.dialog.search_button.isEnabled()) self.assertIn('连接超时', self.dialog.hint_label.text()) def test_cancel_reauthentication_leaves_dialog_open_and_calls_no_service(self): self.expire() with mock.patch.object(backend, 'load_settings', return_value={'zyt_account': 'test-user'}), \ mock.patch.object(qt.QInputDialog, 'getText', return_value=('', False)), \ mock.patch.object(backend, 'reauthenticate_patient_session') as reauth: self.dialog.reauthenticate_button.click() reauth.assert_not_called() self.assertTrue(self.dialog.isVisible()) self.assertTrue(self.dialog.reauthenticate_button.isEnabled()) def test_reauthentication_reloads_permissions_and_retries_only_search_once(self): self.expire() with mock.patch.object(backend, 'load_settings', return_value={'zyt_account': 'test-user'}), \ mock.patch.object(qt.QInputDialog, 'getText', return_value=('synthetic-password', True)), \ mock.patch.object(backend, 'reauthenticate_patient_session', return_value={'ok': True}) as reauth, \ mock.patch.object(backend, 'desktop_patient_context', return_value=CONTEXT) as context, \ mock.patch.object(backend, 'search_desktop_patients', return_value=RESULT) as search, \ mock.patch.object(backend, 'bind_desktop_patient') as bind: self.dialog.reauthenticate_button.click() reauth.assert_called_once_with('synthetic-password') context.assert_called_once_with('account-test', 'conversation-test', person_id='person-test') search.assert_called_once_with('测试') bind.assert_not_called() self.assertFalse(self.dialog.reauthenticate_button.isVisible()) self.assertEqual(self.dialog.result_table.rowCount(), 1) self.assertFalse(self.dialog.changed) def test_reauthenticated_account_without_permission_does_not_search(self): self.expire() context = {**CONTEXT, 'permissions': {'can_search': False, 'can_bind': False, 'reason': '无患者权限'}} with mock.patch.object(backend, 'search_desktop_patients') as search: self.dialog._reauthentication_finished({'ok': True, 'context': context}) search.assert_not_called() self.assertFalse(self.dialog.search_button.isEnabled()) self.assertFalse(self.dialog.bind_button.isEnabled()) self.assertIn('无患者权限', self.dialog.hint_label.text()) def test_reauthentication_failure_allows_retry_without_password_in_display(self): self.expire() with mock.patch.object(backend, 'load_settings', return_value={'zyt_account': 'test-user'}), \ mock.patch.object(qt.QInputDialog, 'getText', return_value=('synthetic-password', True)), \ mock.patch.object(backend, 'reauthenticate_patient_session', side_effect=backend.BackendError('密码错误')): self.dialog.reauthenticate_button.click() self.assertTrue(self.dialog.reauthenticate_button.isEnabled()) self.assertIn('密码错误', self.dialog.hint_label.text()) self.assertNotIn('synthetic-password', self.dialog.hint_label.text()) self.assertTrue(self.dialog.isVisible()) def test_expired_bind_never_retries_mutation_automatically(self): self.dialog._search_finished({'ok': True, 'result': RESULT}) with mock.patch.object(backend, 'bind_desktop_patient', side_effect=backend.PatientSessionExpired('过期')) as bind: self.dialog.bind_button.click() bind.assert_called_once() self.assertTrue(self.dialog.reauthenticate_button.isVisible()) self.assertFalse(self.dialog.changed) def test_keepalive_expiry_is_a_local_warning_and_does_not_stop_monitor(self): host = SimpleNamespace(_patient_keepalive_running=True, _patient_session_state='unknown', append_log=mock.Mock(), stop_monitoring=mock.Mock(), _handle_forced_desktop_logout=mock.Mock()) qt.MainWindow._patient_session_checked(host, {'status': 'expired'}) qt.MainWindow._patient_session_checked(host, {'status': 'expired'}) host.append_log.assert_called_once() host.stop_monitoring.assert_not_called() host._handle_forced_desktop_logout.assert_not_called() self.assertFalse(host._patient_keepalive_running)