265 lines
12 KiB
Python
265 lines
12 KiB
Python
"""Excluded protocol sessions use synthetic DB/sender and temporary durable queues."""
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from protocol_engine import ProtocolBot, session_key
|
|
from test_native_protocol_pipeline import Database, Sender, message, ACCOUNT, CONV
|
|
from wecom_native_sender import DeliveryUnknown, SendCancelled
|
|
from wxwork_db import WXWorkDB, is_personal_chat
|
|
|
|
|
|
class ProtocolSessionExclusions(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory(prefix='protocol-exclusions-')
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name)
|
|
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),
|
|
mock.patch('protocol_engine.NativeSender', side_effect=AssertionError('native sender forbidden')),
|
|
mock.patch('protocol_engine.discover', side_effect=AssertionError('native discovery forbidden')),
|
|
mock.patch('socket.create_connection', side_effect=AssertionError('network forbidden')),
|
|
mock.patch('ai_config.AI_REVIEW_RULES', []),
|
|
):
|
|
self.enterContext(patcher)
|
|
self.db = Database()
|
|
self.sender = Sender(self.db)
|
|
self.generate = mock.Mock(return_value='synthetic reply')
|
|
self.logs = []
|
|
self.bot = self.make_bot()
|
|
self.key = session_key(ACCOUNT, CONV)
|
|
|
|
def make_bot(self):
|
|
bot = ProtocolBot(db=self.db, sender=self.sender,
|
|
identity={'accountId': ACCOUNT, 'pid': 1},
|
|
path=self.root / 'queue.json', generate=self.generate,
|
|
log=self.logs.append)
|
|
bot.message_batch_window_seconds = bot.send_delay_seconds = 0
|
|
return bot
|
|
|
|
def seed(self, *, name='企小码会话管理', conv=CONV, account=ACCOUNT, **overrides):
|
|
event = message(8, account=account, conv=conv)
|
|
state = dict(transport='protocol', account=account, conv_id=conv,
|
|
display_name=name, dedup_key=event['dedup_key'],
|
|
created_at=1, ready_at=0, stage='awaiting_review',
|
|
send_state='', reply_text='synthetic old draft',
|
|
staged_reply_text='synthetic old draft', awaiting_review=True,
|
|
content_type=2)
|
|
state.update(overrides)
|
|
key = session_key(account, conv)
|
|
self.bot._pending[key] = state
|
|
self.bot._seen[key] = event['dedup_key']
|
|
self.bot._save()
|
|
return key, state
|
|
|
|
def saved(self):
|
|
return json.loads(self.bot.path.read_text(encoding='utf-8'))
|
|
|
|
def context_metadata(self, **fields):
|
|
read = self.db.get_conversation_context_by_id
|
|
def context(*args, **kwargs):
|
|
result = read(*args, **kwargs)
|
|
if result:
|
|
result.update(fields)
|
|
return result
|
|
self.db.get_conversation_context_by_id = context
|
|
|
|
def test_named_system_sessions_never_generate_or_enter_review(self):
|
|
self.bot.send_mode = 'review'
|
|
for index, name in enumerate(('企小码', '企小码会话管理'), 1):
|
|
self.db.add({**message(index), 'display_name': name})
|
|
self.bot.poll_once()
|
|
self.generate.assert_not_called()
|
|
self.assertFalse(self.sender.calls)
|
|
self.assertFalse(self.bot._pending)
|
|
self.assertEqual(self.saved()['seen'][self.key], message(2)['dedup_key'])
|
|
|
|
def test_group_ids_and_structured_kinds_never_generate(self):
|
|
cases = [message(1, conv='R:room'),
|
|
{**message(2), 'kind': 'group'},
|
|
{**message(3), 'is_group': True},
|
|
{**message(4), 'conversation_type': 'group_chat'}]
|
|
for item in cases:
|
|
self.db.add(item)
|
|
self.bot.poll_once()
|
|
self.generate.assert_not_called()
|
|
self.assertFalse(self.sender.calls)
|
|
self.assertFalse(self.bot._pending)
|
|
|
|
def test_personal_message_mentions_of_groups_or_application_are_not_filtered(self):
|
|
self.db.add(message(2, text='请介绍群聊和企小码会话管理功能'))
|
|
self.bot.poll_once()
|
|
self.generate.assert_called_once()
|
|
self.assertEqual(len(self.sender.calls), 1)
|
|
|
|
def test_personal_name_containing_group_word_is_not_group_identity(self):
|
|
self.db.add({**message(2), 'display_name': '客户群咨询'})
|
|
self.bot.poll_once()
|
|
self.generate.assert_called_once()
|
|
self.assertEqual(len(self.sender.calls), 1)
|
|
|
|
def test_excluded_sessions_do_not_block_other_personal_session(self):
|
|
self.db.add({**message(2), 'display_name': '企小码会话管理'})
|
|
other = 'S:100_201'
|
|
self.db.add(message(3, conv=other))
|
|
self.bot.poll_once()
|
|
self.assertEqual([call[2] for call in self.sender.calls], [other])
|
|
self.assertFalse(self.bot._pending)
|
|
|
|
def test_restart_discards_old_drafts_and_reviews_preserving_dedup(self):
|
|
cases = [('企小码', CONV, 'queued'), ('企小码会话管理', CONV, 'awaiting_review'),
|
|
('普通标题', 'R:old-room', 'awaiting_review')]
|
|
for name, conv, stage in cases:
|
|
with self.subTest(name=name, conv=conv, stage=stage):
|
|
self.bot._pending = {}
|
|
key, state = self.seed(name=name, conv=conv, stage=stage)
|
|
restored = self.make_bot()
|
|
self.assertNotIn(key, restored._pending)
|
|
self.assertEqual(restored._seen[key], state['dedup_key'])
|
|
self.assertNotIn(key, self.saved()['pending'])
|
|
self.generate.assert_not_called()
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_restart_keeps_attempted_delivery_evidence(self):
|
|
cases = [('sending', ''), ('receipt_check', ''), ('unknown', ''),
|
|
('sent_uncommitted', ''), ('failed', ''),
|
|
('awaiting_review', 'uncertain'), ('awaiting_review', 'failed')]
|
|
for stage, send_state in cases:
|
|
with self.subTest(stage=stage, send_state=send_state):
|
|
self.bot._pending = {}
|
|
key, state = self.seed(stage=stage, send_state=send_state,
|
|
last_error='synthetic receipt diagnostic')
|
|
restored = self.make_bot()
|
|
restored.poll_once()
|
|
self.assertIn(key, restored._pending)
|
|
self.assertEqual(restored._pending[key]['reply_text'], state['reply_text'])
|
|
self.assertEqual(restored._pending[key]['dedup_key'], state['dedup_key'])
|
|
self.assertEqual(restored._pending[key]['last_error'], 'synthetic receipt diagnostic')
|
|
self.assertIn(key, self.saved()['pending'])
|
|
self.generate.assert_not_called()
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_foreign_account_draft_is_never_cleared_by_current_account_policy(self):
|
|
key, state = self.seed(account='101', conv='S:101_200')
|
|
restored = self.make_bot()
|
|
restored.poll_once()
|
|
for field, value in state.items():
|
|
self.assertEqual(restored._pending[key][field], value)
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_new_excluded_message_cannot_replace_unknown_send(self):
|
|
key, state = self.seed(name='普通客户', send_state='uncertain', stage='receipt_check')
|
|
old_event = state['dedup_key']
|
|
self.db.add({**message(9), 'display_name': '企小码'})
|
|
self.bot.poll_once()
|
|
self.assertIs(self.bot._pending[key], state)
|
|
self.assertEqual(state['dedup_key'], old_event)
|
|
self.assertEqual(self.bot._seen[key], old_event)
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_excluded_legacy_manual_or_error_tasks_are_removed_before_review(self):
|
|
for stage in ('queued', 'awaiting_review', 'error', 'manual_takeover'):
|
|
with self.subTest(stage=stage):
|
|
self.bot._pending = {}
|
|
key, state = self.seed(stage=stage, manual_required=True)
|
|
self.bot.poll_once()
|
|
self.assertNotIn(key, self.bot._pending)
|
|
self.assertNotIn(key, self.saved()['pending'])
|
|
self.generate.assert_not_called()
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_latest_context_identity_is_checked_before_generation(self):
|
|
self.context_metadata(display_name='企小码会话管理')
|
|
self.db.add(message(2))
|
|
self.bot.poll_once()
|
|
self.generate.assert_not_called()
|
|
self.assertFalse(self.bot._pending)
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_context_reloaded_for_generation_can_exclude_group(self):
|
|
read = self.db.get_conversation_context_by_id
|
|
def context(*args, **kwargs):
|
|
result = read(*args, **kwargs)
|
|
if result and kwargs.get('limit') == 101:
|
|
result['kind'] = 'group'
|
|
return result
|
|
self.db.get_conversation_context_by_id = context
|
|
self.db.add(message(2))
|
|
self.bot.poll_once()
|
|
self.generate.assert_not_called()
|
|
self.assertFalse(self.bot._pending)
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_latest_last_message_metadata_is_checked_even_without_context_kind(self):
|
|
self.bot.message_batch_window_seconds = 100
|
|
self.db.add(message(2))
|
|
self.bot.poll_once()
|
|
self.bot._pending[self.key]['ready_at'] = 0
|
|
self.db.messages[-1]['kind'] = 'group'
|
|
self.bot.poll_once()
|
|
self.generate.assert_not_called()
|
|
self.assertFalse(self.bot._pending)
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_identity_changed_during_generation_drops_draft_before_review_or_send(self):
|
|
self.bot.send_mode = 'review'
|
|
def generate(context):
|
|
self.context_metadata(display_name='企小码')
|
|
return 'obsolete draft'
|
|
self.bot.generate = generate
|
|
self.db.add(message(2))
|
|
self.bot.poll_once()
|
|
self.assertFalse(self.bot._pending)
|
|
self.assertFalse(self.sender.calls)
|
|
|
|
def test_final_native_guard_cancels_then_clears_only_unsent_draft(self):
|
|
def send(*args, guard, **kwargs):
|
|
self.context_metadata(display_name='企小码')
|
|
self.assertEqual(self.bot._pending[self.key]['send_state'], 'sending')
|
|
self.assertFalse(guard())
|
|
# Guard cannot discard sending evidence before sender confirms cancellation.
|
|
self.assertEqual(self.bot._pending[self.key]['send_state'], 'sending')
|
|
raise SendCancelled('synthetic pre-call cancellation')
|
|
self.sender.send = send
|
|
self.db.add(message(2))
|
|
self.bot.poll_once()
|
|
self.assertFalse(self.bot._pending)
|
|
self.assertFalse(self.sender.calls)
|
|
self.assertEqual(self.saved()['seen'][self.key], message(2)['dedup_key'])
|
|
|
|
def test_unknown_delivery_after_excluded_guard_preserves_evidence(self):
|
|
def send(*args, guard, **kwargs):
|
|
self.context_metadata(kind='group')
|
|
self.assertFalse(guard())
|
|
raise DeliveryUnknown('synthetic ambiguous sender result')
|
|
self.sender.send = send
|
|
self.db.add(message(2))
|
|
self.bot.poll_once()
|
|
self.assertEqual(self.bot._pending[self.key]['send_state'], 'uncertain')
|
|
self.assertTrue(self.bot.needs_attention)
|
|
self.assertIn(self.key, self.saved()['pending'])
|
|
self.assertEqual(self.bot._pending[self.key]['reply_text'], 'synthetic reply')
|
|
|
|
def test_duplicate_excluded_event_does_not_rewrite_queue_or_repeat_log(self):
|
|
item = {**message(2), 'display_name': '企小码'}
|
|
self.bot._upsert(item)
|
|
logs = list(self.logs)
|
|
with mock.patch.object(self.bot, '_save') as save:
|
|
self.bot._upsert(item)
|
|
save.assert_not_called()
|
|
self.assertEqual(self.logs, logs)
|
|
self.assertFalse(self.bot._pending)
|
|
|
|
def test_database_existing_direct_filter_rejects_group_before_content_read(self):
|
|
self.assertFalse(is_personal_chat('R:any-room'))
|
|
self.assertTrue(is_personal_chat(CONV))
|
|
raw_db = WXWorkDB.__new__(WXWorkDB)
|
|
self.assertIsNone(raw_db._parse_message(ACCOUNT, {'conversation_id': 'R:any-room'}))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|