Files
kefu/wechat_rpa/test_handoff_safety.py
T
2026-09-21 10:34:06 +08:00

128 lines
6.8 KiB
Python

"""State-only receipt records and stale UI snapshots must fail closed."""
import json
from pathlib import Path
import queue
import tempfile
from types import SimpleNamespace
import unittest
from unittest import mock
from gui_runtime import BotThread, delete_pending_reply_file, handoff_pending_reply_file
class OfflineHandoffSafetyTests(unittest.TestCase):
def test_sending_stage_cannot_be_removed_even_after_confirmation(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
path = root/'queue.json'
original = json.dumps({'pending': {'task': {'stage': 'sending'}}})
path.write_text(original, encoding='utf-8')
for action in (delete_pending_reply_file, handoff_pending_reply_file):
with self.subTest(action=action.__name__), mock.patch('queue_log.application_data_dir', return_value=root):
kwargs = {'confirm_uncertain': True} if action is handoff_pending_reply_file else {}
self.assertEqual(action(['task'], str(path), **kwargs)['protected'], ['task'])
self.assertEqual(path.read_text(encoding='utf-8'), original)
def test_all_receipt_variants_require_explicit_confirmation_at_mutation_time(self):
variants = [{'stage': state} for state in ('receipt_check', 'uncertain', 'unknown')]
variants += [{'send_state': state} for state in ('uncertain', 'sent_uncommitted', 'unknown')]
for state in variants:
with self.subTest(state=state), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
path = root/'queue.json'
original = json.dumps({'transport': 'protocol', 'pending': {'task': dict(state, dedup_key='event')}, 'seen': {}})
path.write_text(original, encoding='utf-8')
with mock.patch('queue_log.application_data_dir', return_value=root):
ordinary = delete_pending_reply_file(['task'], str(path))
self.assertEqual(ordinary['protected'], ['task'])
self.assertEqual(path.read_text(encoding='utf-8'), original)
blocked = handoff_pending_reply_file(['task'], str(path))
self.assertEqual(blocked['requires_confirmation'], ['task'])
self.assertEqual(path.read_text(encoding='utf-8'), original)
accepted = handoff_pending_reply_file(['task'], str(path), confirm_uncertain=True)
self.assertEqual(accepted['handed_off'], ['task'])
self.assertEqual(json.loads(path.read_text(encoding='utf-8'))['seen'], {'task': 'event'})
def test_startup_confirmation_is_per_task_not_batch_wide(self):
thread = BotThread(queue.Queue(), '合成回复', .2)
thread.handoff_pending_tasks(['normal'])
thread.handoff_pending_tasks(['uncertain'], confirm_uncertain=True)
bot = SimpleNamespace(handoff_pending_replies=mock.Mock(return_value={}))
thread._drain_queued_handoffs(bot)
self.assertEqual(bot.handoff_pending_replies.call_args_list, [
mock.call({'normal'}, confirm_uncertain=False), mock.call({'uncertain'}, confirm_uncertain=True)])
thread._drain_queued_handoffs(bot)
self.assertEqual(bot.handoff_pending_replies.call_count, 2)
try:
from protocol_engine import ProtocolBot, session_key
from test_native_protocol_pipeline import Database, Sender, message, ACCOUNT, CONV
except ImportError:
ProtocolBot = None
@unittest.skipIf(ProtocolBot is None, 'visual-only distribution has no protocol engine')
class ProtocolHandoffSafetyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.db = Database()
self.sender = Sender(self.db)
self.generate = mock.Mock(return_value='合成回复')
for patcher in (mock.patch('protocol_engine.application_data_dir', return_value=self.root),
mock.patch('queue_log.application_data_dir', return_value=self.root)):
patcher.start()
self.addCleanup(patcher.stop)
self.key = session_key(ACCOUNT, CONV)
self.bot = self.make_bot()
def make_bot(self):
return ProtocolBot(db=self.db, sender=self.sender, identity={'accountId': ACCOUNT, 'pid': 1},
path=self.root/'queue.json', generate=self.generate, log=lambda _: None)
def set_state(self, **state):
self.bot._pending[self.key] = dict(account=ACCOUNT, conv_id=CONV, dedup_key=message()['dedup_key'], **state)
self.bot._save()
def test_stale_ui_snapshot_cannot_handoff_newly_uncertain_response(self):
self.set_state(stage='receipt_check', send_state='uncertain')
self.bot.needs_attention = True
thread = BotThread(queue.Queue(), '合成回复', .2)
thread.bot = self.bot
blocked = thread.handoff_pending_tasks([self.key])
self.assertEqual(blocked['requires_confirmation'], [self.key])
self.assertIn(self.key, self.bot._pending)
self.assertTrue(self.bot.needs_attention)
accepted = thread.handoff_pending_tasks([self.key], confirm_uncertain=True)
self.assertEqual(accepted['handed_off'], [self.key])
self.assertFalse(self.bot.needs_attention)
self.assertFalse(self.sender.calls)
def test_stage_only_sending_is_protected_then_restores_as_unknown_receipt(self):
self.set_state(stage='sending', reply_text='可能已发送')
self.assertEqual(self.bot.handoff_pending_replies([self.key], confirm_uncertain=True)['protected'], [self.key])
restored = self.make_bot()
self.assertTrue(restored.needs_attention)
self.assertEqual(restored._pending[self.key]['send_state'], 'uncertain')
self.assertEqual(restored.handoff_pending_replies([self.key])['requires_confirmation'], [self.key])
restored.poll_once()
self.assertFalse(self.sender.calls)
self.generate.assert_not_called()
def test_stage_only_receipt_blocks_approval_retry_cancel_and_incoming_replacement(self):
self.set_state(stage='receipt_check', reply_text='可能已发送', awaiting_review=True)
for action in (self.bot.approve_pending_replies, self.bot.retry_pending_replies, self.bot.cancel_pending_replies):
self.assertEqual(action([self.key])['protected'], [self.key])
self.assertEqual(self.bot.handoff_pending_replies([self.key])['requires_confirmation'], [self.key])
self.bot._upsert(message(2))
self.assertEqual(self.bot._pending[self.key]['dedup_key'], message()['dedup_key'])
self.bot.poll_once()
self.assertFalse(self.sender.calls)
self.generate.assert_not_called()
if __name__ == '__main__':
unittest.main()