128 lines
6.8 KiB
Python
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()
|