182 lines
7.5 KiB
Python
182 lines
7.5 KiB
Python
"""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)
|