import json import sqlite3 import sys import time from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock import pytest from review_assistant import ReviewAssistantCoordinator, notification_text, _fingerprint ACCOUNT = '1688856770803435' CONV = 'M:12345678' RECIPIENT = 'M:98765432' def msg(row=1, own=False, **overrides): result = dict(account=ACCOUNT, conv_id=CONV, rowid=row, is_self=own, sender_id=ACCOUNT if own else '12345678', server_id=str(row + 100), dedup_key=f'{ACCOUNT}:{CONV}:server:{row + 100}', content='人工回复' if own else '客户问题', send_time=time.time()) result.update(overrides) return result def context(*messages): return dict(account=ACCOUNT, conv_id=CONV, display_name='测试客户', messages=list(messages), last_message=messages[-1]) @pytest.fixture def setup(tmp_path): settings = {'review_assistant_accounts': {ACCOUNT: {'enabled': True, 'recipient_conv_id': RECIPIENT}}} service = ReviewAssistantCoordinator(tmp_path, lambda: settings, ACCOUNT) state = dict(account=ACCOUNT, conv_id=CONV, display_name='测试客户', awaiting_review=True, stage='awaiting_review', send_state='', review_reason='新风控', reply_text='未发送的草稿') incoming = msg() state['assistant_review_id'] = service.ensure_review(state, context(incoming)) assert state['assistant_review_id'] return service, state, incoming, settings def episode(service): with service._connect() as conn: row = conn.execute('SELECT data FROM episodes ORDER BY rowid DESC LIMIT 1').fetchone() return json.loads(row[0]) def test_disabled_is_lazy_and_uses_supplied_root(tmp_path): service = ReviewAssistantCoordinator(tmp_path, account=ACCOUNT) assert not service.enabled() assert not service.ensure_review({}, {}) service.enqueue_notifications() assert service._thread is None assert not service.path.exists() def test_deduplicate_episode_and_restart(setup): service, state, incoming, settings = setup assert service.ensure_review(state, context(incoming, msg(2))) == state['assistant_review_id'] restarted = ReviewAssistantCoordinator(service.root, lambda: settings, ACCOUNT) assert restarted.has_pending(CONV) assert restarted.ensure_review(state, context(incoming)) == state['assistant_review_id'] assert restarted._thread is None def test_human_completion_is_idempotent_and_new_risk_is_new_episode(setup): service, state, incoming, _ = setup human = msg(2, True) evidence = service.reconcile(state, context(incoming, human, msg(3))) assert evidence['server_id'] == human['server_id'] assert service.resumed(CONV) assert not service.has_pending(CONV) assert service.reconcile(state, context(incoming, human)) == evidence assert service.ensure_review(state, context(incoming, human)) == state['assistant_review_id'] fresh = dict(state, assistant_review_id='') fresh_id = service.ensure_review(fresh, context(incoming, human, msg(3))) assert fresh_id != state['assistant_review_id'] assert service.has_pending(CONV) assert not service.resumed(CONV) @pytest.mark.parametrize('changes', [ {'account': '999'}, {'conv_id': 'M:222'}, {'sender_id': '999'}, {'is_self': False}, {'server_id': '0'}, {'dedup_key': ''}, {'rowid': 1}, {'send_time': time.time() - 86400}, {'content': ''}, {'content_type': 1002}, {'is_system': True}, {'rowid': float('nan')}, {'send_time': float('nan')}, {'server_id': 'invalid'}, ]) def test_untrusted_or_historical_outbound_does_not_complete(setup, changes): service, state, incoming, _ = setup assert service.reconcile(state, context(incoming, msg(2, True, **changes))) is None assert service.has_pending(CONV) @pytest.mark.parametrize('send_state', ['sending', 'unrecognized_state']) def test_active_or_unrecognized_delivery_protection_survives_human_reply(setup, send_state): service, state, incoming, _ = setup state['send_state'] = send_state assert service.reconcile(state, context(incoming, msg(2, True))) is None def test_bot_receipt_and_attempt_are_not_human(setup): service, state, incoming, _ = setup bot = msg(2, True, content=state['reply_text']) service.record_automated_attempt(state) assert service.reconcile(state, context(incoming, bot)) is None service.record_automated_send(state, dict(status='confirmed', accountId=ACCOUNT, conversationId=CONV, serverId=bot['server_id'])) assert service.reconcile(state, context(incoming, bot)) is None assert service.reconcile(state, context(incoming, bot, msg(3, True))) def test_unknown_native_send_fingerprint_cannot_release(setup): service, state, incoming, _ = setup bot = msg(2, True) with sqlite3.connect(service.root / 'protocol_sends.sqlite3') as db: db.execute('CREATE TABLE sends(request_id,fingerprint,status,result,created)') db.execute('INSERT INTO sends VALUES(?,?,?,?,?)', ('x', _fingerprint(ACCOUNT, CONV, bot['content']), 'unknown', '{}', time.time())) assert service.reconcile(state, context(incoming, bot)) is None def test_local_approve_cancels_reminder_without_granting_resume(setup): service, state, _, _ = setup state['approved'] = True service.complete_local_review(state) assert episode(service)['status'] == 'locally_approved' assert not service.has_pending(CONV) assert not service.resumed(CONV) def test_recipient_excluded_and_account_config_isolated(setup): service, state, incoming, settings = setup assert service.is_recipient(RECIPIENT) incoming['conv_id'] = RECIPIENT assert not service.ensure_review(dict(state, conv_id=RECIPIENT), dict(context(incoming), conv_id=RECIPIENT)) settings['review_assistant_accounts'][ACCOUNT]['enabled'] = False assert not service.enabled() assert not service.reconcile(state, context(incoming, msg(2, True))) def test_notification_has_detail_and_utf8_limit(setup): item = episode(setup[0]) item.update(context='内容很长🙂' * 5000, draft='草稿内容🙂' * 5000, name='名字🙂' * 500) text = notification_text(item) assert len(text.encode()) <= 4000 for phrase in (ACCOUNT, CONV, '审核原因', '尚未发送', '手机端直接回复', '回复本提醒不会审批'): assert phrase in text def native_fakes(monkeypatch, service, outcome=None, valid=True, identity=ACCOUNT): sender = Mock() sender.send.return_value = {'status': 'confirmed', 'serverId': '909'} if outcome: sender.send.side_effect = outcome db = Mock() monkeypatch.setitem(sys.modules, 'wecom_native_sender', SimpleNamespace( NativeSender=Mock(return_value=sender), discover=lambda: {'accountId': identity, 'pid': 123})) monkeypatch.setitem(sys.modules, 'reply_database', SimpleNamespace(LiveReplyDatabase=SimpleNamespace(_open_database=lambda **kw: db))) monkeypatch.setitem(sys.modules, 'review_assistant_contacts', SimpleNamespace(validate_recipient=lambda *args: {'ok': valid, 'reason': '无法确认接收人'})) lock = SimpleNamespace(try_acquire=Mock(return_value=True), release=Mock()) monkeypatch.setitem(sys.modules, 'send_lock', lock) return sender, db, lock def test_notification_success_and_worker_owned_resources(setup, monkeypatch): service = setup[0] sender, db, lock = native_fakes(monkeypatch, service) item = episode(service) service._notify(item) assert episode(service)['notification'] == 'sent' assert sender.send.call_args.args[:4] == ('review-assistant:' + item['id'], 123, ACCOUNT, RECIPIENT) assert sender.send.call_args.kwargs['guard']() db.close.assert_called_once() sender.close.assert_called_once() lock.release.assert_called_once() @pytest.mark.parametrize('error_name,expected', [('DeliveryUnknown', 'unknown'), ('DeliveryFailed', 'unknown'), ('NativeUnavailable', 'retry'), ('SendCancelled', 'retry')]) def test_notification_outcomes_never_release_customer(setup, monkeypatch, error_name, expected): service = setup[0] error = type(error_name, (Exception,), {})('模拟发送结果') sender, _, _ = native_fakes(monkeypatch, service, outcome=error) service._notify(episode(service)) assert episode(service)['notification'] == expected assert service.has_pending(CONV) assert not service.resumed(CONV) @pytest.mark.parametrize('valid,identity', [(False, ACCOUNT), (True, '999')]) def test_recipient_or_account_change_never_sends(setup, monkeypatch, valid, identity): service = setup[0] sender, _, _ = native_fakes(monkeypatch, service, valid=valid, identity=identity) service._notify(episode(service)) sender.send.assert_not_called() def test_completed_or_changed_recipient_cancels_queued_notification(setup, monkeypatch): service, state, incoming, settings = setup sender, _, _ = native_fakes(monkeypatch, service) item = episode(service) settings['review_assistant_accounts'][ACCOUNT]['recipient_conv_id'] = 'M:777' service._notify(item) sender.send.assert_not_called() settings['review_assistant_accounts'][ACCOUNT]['recipient_conv_id'] = RECIPIENT service.reconcile(state, context(incoming, msg(2, True))) service._notify(item) sender.send.assert_not_called() def test_cancel_while_disabled_closes_episode_without_resuming(setup): service, state, _, settings = setup settings['review_assistant_accounts'][ACCOUNT]['enabled'] = False service.cancel_review(state) assert episode(service)['status'] == 'cancelled' settings['review_assistant_accounts'][ACCOUNT]['enabled'] = True assert not service.has_pending(CONV) assert not service.resumed(CONV) assert not service._notification_guard(episode(service)) def test_old_cancel_tombstone_does_not_clear_new_resume(setup): service, state, incoming, _ = setup human = msg(2, True) assert service.reconcile(state, context(incoming, human)) fresh = dict(state, assistant_review_id='') fresh['assistant_review_id'] = service.ensure_review(fresh, context(incoming, human, msg(3))) assert service.reconcile(fresh, context(incoming, human, msg(3), msg(4, True))) assert service.cancel_review(state) assert service.resumed(CONV)