Files
kefu/deploy/skip-sessions-20260917/test_protocol_session_exclusions.py
T
2026-09-21 10:34:06 +08:00

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()