Files
kefu/wechat_rpa/tests/test_review_assistant.py
T
2026-09-21 10:34:06 +08:00

230 lines
10 KiB
Python

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)