"""Synthetic customer-recipient reminders; no live databases or native sends.""" import json import sys import time from types import SimpleNamespace from unittest.mock import Mock import pytest from review_assistant import ReviewAssistantCoordinator, notification_text ACCOUNT = '1688856770803435' PEER = '7881302444555666' CUSTOMER = 'M:12345678' RECIPIENTS = ['M:' + PEER, f'S:{ACCOUNT}_{PEER}', f'S:{PEER}_{ACCOUNT}'] class SendCancelled(Exception): pass class DeliveryUnknown(Exception): pass def make_service(tmp_path, recipient): settings = {'review_assistant_accounts': {ACCOUNT: { 'enabled': True, 'recipient_conv_id': recipient}}} service = ReviewAssistantCoordinator(tmp_path, lambda: settings, ACCOUNT) return service, settings def make_context(conv=CUSTOMER, row=1): incoming = {'account': ACCOUNT, 'conv_id': conv, 'rowid': row, 'is_self': False, 'sender_id': '12345678', 'server_id': str(100 + row), 'dedup_key': f'{ACCOUNT}:{conv}:server:{100 + row}', 'content': '合成客户问题', 'send_time': time.time()} return {'account': ACCOUNT, 'conv_id': conv, 'display_name': '合成客户', 'messages': [incoming], 'last_message': incoming} def add_episode(service): state = {'account': ACCOUNT, 'conv_id': CUSTOMER, 'display_name': '合成客户', 'awaiting_review': True, 'stage': 'awaiting_review', 'send_state': '', 'review_reason': '合成审核原因', 'reply_text': '未发送的合成草稿'} state['assistant_review_id'] = service.ensure_review(state, make_context()) assert state['assistant_review_id'] return state def read_episode(service): with service._connect() as conn: return json.loads(conn.execute('SELECT data FROM episodes ORDER BY rowid DESC LIMIT 1').fetchone()[0]) def fake_native(monkeypatch, *, validations=None, failure=None): """Mimic native preflight and final guard without loading native modules.""" validator = Mock(return_value={'ok': True}) if validations is not None: validator.side_effect = validations delivered = Mock() sender, db = Mock(), Mock() def send(*args, guard): if not guard() or not guard(): raise SendCancelled('合成发送门禁取消') delivered(*args) if failure is not None: raise failure return {'status': 'confirmed', 'serverId': 'synthetic-909'} sender.send.side_effect = send monkeypatch.setitem(sys.modules, 'wecom_native_sender', SimpleNamespace( NativeSender=Mock(return_value=sender), discover=lambda: {'accountId': ACCOUNT, 'pid': 123})) monkeypatch.setitem(sys.modules, 'reply_database', SimpleNamespace( LiveReplyDatabase=SimpleNamespace(_open_database=lambda **kwargs: db))) monkeypatch.setitem(sys.modules, 'review_assistant_contacts', SimpleNamespace(validate_recipient=validator)) lock = SimpleNamespace(try_acquire=Mock(return_value=True), release=Mock()) monkeypatch.setitem(sys.modules, 'send_lock', lock) return validator, sender, db, delivered @pytest.mark.parametrize('recipient', RECIPIENTS) def test_customer_recipient_m_and_both_s_orders_send_reminder(tmp_path, monkeypatch, recipient): service, _ = make_service(tmp_path, recipient) add_episode(service) validator, sender, db, delivered = fake_native(monkeypatch) item = read_episode(service) service._notify(item) assert read_episode(service)['notification'] == 'sent' assert delivered.call_args.args[2:4] == (ACCOUNT, recipient) assert validator.call_count == 3 # initial check and both native gates assert all(call.args == (ACCOUNT, recipient) for call in validator.call_args_list) assert service.has_pending(CUSTOMER) and not service.resumed(CUSTOMER) sender.close.assert_called_once() db.close.assert_called_once() assert '此助理联系人仅接收提醒' in notification_text(item) assert '私人微信' not in notification_text(item) @pytest.mark.parametrize('recipient', RECIPIENTS[1:]) def test_assistant_s_incoming_does_not_create_or_approve_customer_review(tmp_path, recipient): service, _ = make_service(tmp_path, recipient) state = add_episode(service) before = read_episode(service) assistant_state = dict(state, conv_id=recipient, assistant_review_id='') incoming = make_context(recipient) assert service.is_recipient(recipient) assert service.ensure_review(assistant_state, incoming) == '' assert not service.reconcile(state, incoming) assert read_episode(service) == before with service._connect() as conn: assert conn.execute('SELECT COUNT(*) FROM episodes').fetchone()[0] == 1 @pytest.mark.parametrize('recipient', [f'S:111_{PEER}', f'S:{ACCOUNT}_{ACCOUNT}', f'R:{ACCOUNT}_{PEER}', 'customer:' + PEER, 'S:bad_' + ACCOUNT, 'M:' + ACCOUNT]) def test_unbound_group_placeholder_or_self_recipient_disabled(tmp_path, recipient): service, _ = make_service(tmp_path, recipient) assert not service.enabled() assert not service.is_recipient(recipient) assert service.ensure_review({'account': ACCOUNT, 'conv_id': CUSTOMER}, make_context()) == '' assert not service.path.exists() @pytest.mark.parametrize('last_check', [{'ok': False, 'reason': '客户关系已解除'}, RuntimeError('合成关系读取失败')]) def test_relationship_revoked_or_unreadable_at_final_native_guard_blocks_send(tmp_path, monkeypatch, last_check): service, _ = make_service(tmp_path, RECIPIENTS[1]) add_episode(service) validator, _, _, delivered = fake_native(monkeypatch, validations=[{'ok': True}, {'ok': True}, last_check]) service._notify(read_episode(service)) assert validator.call_count == 3 delivered.assert_not_called() assert read_episode(service)['notification'] == 'retry' assert service.has_pending(CUSTOMER) and not service.resumed(CUSTOMER) def test_recipient_changed_during_fresh_relationship_check_blocks_send(tmp_path, monkeypatch): service, settings = make_service(tmp_path, RECIPIENTS[1]) add_episode(service) validator, _, _, delivered = fake_native(monkeypatch) checks = [] def validate(*args): checks.append(args) if len(checks) == 2: settings['review_assistant_accounts'][ACCOUNT]['recipient_conv_id'] = 'M:999' return {'ok': True} validator.side_effect = validate service._notify(read_episode(service)) assert len(checks) == 2 delivered.assert_not_called() assert read_episode(service)['notification'] == 'retry' assert service.has_pending(CUSTOMER) def test_unknown_notification_survives_restart_without_retry(tmp_path, monkeypatch): service, settings = make_service(tmp_path, RECIPIENTS[1]) add_episode(service) _, _, _, delivered = fake_native(monkeypatch, failure=DeliveryUnknown('合成回执未知')) service._notify(read_episode(service)) delivered.assert_called_once() assert read_episode(service)['notification'] == 'unknown' restarted = ReviewAssistantCoordinator(tmp_path, lambda: settings, ACCOUNT) restarted._notify = Mock() waits = [] def wait(_timeout): waits.append(True) if len(waits) == 2: restarted._stop.set() restarted._wake = SimpleNamespace(wait=wait, clear=lambda: None) restarted._worker() restarted._notify.assert_not_called() assert read_episode(restarted)['notification'] == 'unknown' assert service.has_pending(CUSTOMER) and not service.resumed(CUSTOMER)