230 lines
10 KiB
Python
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)
|