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