"""Synthetic manual/review lifecycle coverage; no real queues, credentials or I/O.""" import hashlib import json import sqlite3 import time from pathlib import Path import pytest from review_assistant import ( ReviewAssistantCoordinator, _fingerprint, manual_completion_blocked, notification_summary, notification_text, ) ACCOUNT = '168800000000001' CONV = f'S:{ACCOUNT}_101' ASSISTANT = f'S:{ACCOUNT}_202' def message(row=1, own=False, **values): result = dict(account=ACCOUNT, conv_id=CONV, rowid=row, is_self=own, sender_id=ACCOUNT if own else '101', server_id=str(100 + row), dedup_key=f'{CONV}:server:{100 + row}', content='人工答复' if own else '客户问题', send_time=time.time(), content_type=2) result.update(values) return result def context(*messages): return dict(account=ACCOUNT, conv_id=CONV, display_name='合成客户', messages=list(messages), last_message=messages[-1]) def make_service(tmp_path, **changes): settings = {'review_assistant_accounts': {ACCOUNT: {'enabled': True, 'recipient_conv_id': ASSISTANT}}} service = ReviewAssistantCoordinator(tmp_path, lambda: settings, ACCOUNT) state = dict(account=ACCOUNT, conv_id=CONV, display_name='合成客户', stage='error', manual_required=True, manual_reason='语音暂未转写', send_state='', reply_text='') state.update(changes) return service, state, settings def 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 register(service, state, *messages): state['assistant_review_id'] = service.ensure_review(state, context(*messages)) assert state['assistant_review_id'] return episode(service) @pytest.mark.parametrize('changes', [ {'manual_required': True}, {'manual_required': False, 'stage': 'error'}, {'manual_required': False, 'stage': 'failed'}, {'manual_required': False, 'stage': 'reading', 'foreign_draft_text': '人工未发送草稿'}, {'manual_required': False, 'stage': 'receipt_check', 'send_state': 'uncertain'}, {'manual_required': False, 'stage': 'unknown', 'send_state': 'unknown'}, {'manual_required': False, 'stage': 'failed', 'send_state': 'failed'}, {'manual_required': False, 'stage': 'receipt_check', 'send_state': 'sent_uncommitted'}, {'manual_required': False, 'stage': 'awaiting_review', 'awaiting_review': True}, ]) def test_every_ui_needs_human_case_creates_one_detailed_episode(tmp_path, changes): service, state, _ = make_service(tmp_path, **changes) incoming = message(content='', content_type=14) item = register(service, state, incoming) assert item['handling_kind'] == 'manual' assert '客户需人工处理' in item['notification_text'] assert '处理原因' in item['notification_text'] assert '暂无草稿' in item['notification_text'] assert '非文本或暂未解析' in item['notification_text'] assert '人工已完成' in item['notification_text'] assert len(item['notification_text'].encode('utf-8')) <= 4000 assert service.ensure_review(state, context(incoming)) == item['id'] with service._connect() as conn: assert conn.execute('SELECT COUNT(*) FROM episodes').fetchone()[0] == 1 def test_ordinary_queued_task_does_not_create_a_reminder(tmp_path): service, state, _ = make_service(tmp_path, manual_required=False, stage='queued', manual_reason='') assert not service.ensure_review(state, context(message())) assert not service.path.exists() def test_review_to_manual_preserves_id_and_baseline_updates_only_unattempted_text(tmp_path): service, state, _ = make_service(tmp_path, manual_required=False, stage='awaiting_review', awaiting_review=True, manual_reason='', review_reason='初次风控', reply_text='初始AI草稿') incoming = message() before = register(service, state, incoming) state.update(manual_required=True, manual_reason='需人工确认图片', stage='error', awaiting_review=False) later = message(2, content='新增图片', content_type=3) assert service.ensure_review(state, context(incoming, later)) == before['id'] after = episode(service) for field in ('created', 'baseline_row', 'baseline_time', 'evidence_since', 'trigger', 'recipient', 'attempts'): assert after[field] == before[field] assert after['handling_kind'] == 'manual' and after['reason'] == '需人工确认图片' assert after['notification_text'] != before['notification_text'] assert not service._notification_guard(before) # Stale worker snapshot cannot dispatch old content. assert service._notification_guard(after) @pytest.mark.parametrize('notification', ['sending', 'sent', 'unknown', 'retry']) def test_attempted_or_delivered_notification_never_changes_payload_or_requeues(tmp_path, notification): service, state, _ = make_service(tmp_path, manual_required=False, stage='awaiting_review', awaiting_review=True, manual_reason='', review_reason='审核原因', reply_text='草稿') incoming = message() before = register(service, state, incoming) service._notification_update(before, notification=notification, attempts=1) state.update(manual_required=True, manual_reason='转人工处理', stage='error', awaiting_review=False) assert service.ensure_review(state, context(incoming)) == before['id'] after = episode(service) assert after['handling_kind'] == 'manual' assert after['notification_text'] == before['notification_text'] assert after['notification'] == notification and after['attempts'] == 1 @pytest.mark.parametrize('send_state', ['unknown', 'uncertain', 'failed', 'sent_uncommitted']) def test_terminal_delivery_accepts_only_proven_distinct_human_reply_and_preserves_journal(tmp_path, send_state): service, state, _ = make_service(tmp_path, send_state=send_state, stage='receipt_check', reply_text='旧AI草稿') incoming = message() register(service, state, incoming) journal = tmp_path / 'protocol_sends.sqlite3' with sqlite3.connect(journal) as db: db.execute('CREATE TABLE sends(request_id,fingerprint,status,result,created)') db.execute('INSERT INTO sends VALUES(?,?,?,?,?)', ('old-attempt', _fingerprint(ACCOUNT, CONV, '旧AI草稿'), 'unknown', '{}', time.time())) before = hashlib.sha256(journal.read_bytes()).hexdigest() assert not service.reconcile(state, context(incoming, message(2, True, content='旧AI草稿'))) human = message(3, True, content='我已人工处理,请按这个答复') evidence = service.reconcile(state, context(incoming, human)) assert evidence['completed_by_human'] is True assert evidence['previous_send_state'] == send_state assert evidence['previous_stage'] == 'receipt_check' assert service.resumed(CONV) and not service.has_pending(CONV) assert hashlib.sha256(journal.read_bytes()).hexdigest() == before assert notification_summary(tmp_path, ACCOUNT)['message'].startswith('人工已完成') def test_same_old_draft_without_journal_is_still_not_human_proof(tmp_path): service, state, _ = make_service(tmp_path, send_state='unknown', stage='receipt_check', staged_reply_text='旧AI草稿') incoming = message() register(service, state, incoming) assert not service.reconcile(state, context(incoming, message(2, True, content='旧AI草稿'))) assert service.has_pending(CONV) def test_unreadable_outbox_fails_closed_even_for_distinct_reply(tmp_path): service, state, _ = make_service(tmp_path, send_state='unknown', stage='receipt_check') incoming = message() register(service, state, incoming) (tmp_path / 'protocol_sends.sqlite3').write_bytes(b'not a sqlite database') assert not service.reconcile(state, context(incoming, message(2, True))) assert service.has_pending(CONV) @pytest.mark.parametrize('changes', [ {'send_state': 'sending'}, {'stage': 'sending'}, {'stage': 'receipt_check', 'send_state': ''}, {'send_state': 'new_unrecognized_state'}, {'cancel_requested': True}, {'stage': 'cancelled'}, ]) def test_active_unknown_or_cancelled_state_cannot_complete(tmp_path, changes): service, state, _ = make_service(tmp_path) incoming = message() register(service, state, incoming) state.update(changes) assert manual_completion_blocked(state) assert not service.reconcile(state, context(incoming, message(2, True))) assert service.has_pending(CONV) def test_exact_restored_trigger_event_can_recognize_already_arrived_later_phone_reply(tmp_path): service, state, _ = make_service(tmp_path) incoming = message(send_time=time.time() - 100) human = message(2, True, send_time=time.time() - 50) state['database_event'] = dict(incoming) item = register(service, state, incoming, human) assert item['baseline_source'] == 'task_event' assert item['baseline_row'] == incoming['rowid'] assert service.reconcile(state, context(incoming, human))['server_id'] == human['server_id'] @pytest.mark.parametrize('broken', [{'account': '999'}, {'conv_id': 'M:999'}, {'dedup_key': ''}, {'rowid': 999}, {'send_time': 1}]) def test_inexact_restore_event_does_not_lower_registration_baseline(tmp_path, broken): service, state, _ = make_service(tmp_path) incoming = message(send_time=time.time() - 100) human = message(2, True, send_time=time.time() - 50) state['database_event'] = dict(incoming, **broken) item = register(service, state, incoming, human) assert item['baseline_source'] == 'registration' assert item['baseline_row'] == human['rowid'] assert not service.reconcile(state, context(incoming, human)) def test_restored_baseline_still_excludes_historical_automated_attempt(tmp_path): service, state, _ = make_service(tmp_path) incoming = message(send_time=time.time() - 100) automated = message(2, True, send_time=time.time() - 50, content='自动发送内容') state['database_event'] = dict(incoming) register(service, state, incoming, automated) with service._connect() as conn: conn.execute('INSERT INTO automated VALUES(?,?,?,?,?)', (ACCOUNT, CONV, 'attempt:old', _fingerprint(ACCOUNT, CONV, automated['content']), time.time() - 60)) assert not service.reconcile(state, context(incoming, automated)) def test_manual_completion_is_idempotent_new_risk_uses_new_episode(tmp_path): service, state, _ = make_service(tmp_path) incoming = message() first = register(service, state, incoming) human = message(2, True) evidence = service.reconcile(state, context(incoming, human)) assert service.reconcile(state, context(incoming, human)) == evidence fresh = dict(state, assistant_review_id='', manual_reason='新一轮风控') followup = message(3) new_id = service.ensure_review(fresh, context(incoming, human, followup)) assert new_id and new_id != first['id'] assert service.has_pending(CONV) and not service.resumed(CONV) def test_explicit_cancel_and_disabled_settings_never_auto_enable_or_complete(tmp_path): service, state, settings = make_service(tmp_path) incoming = message() register(service, state, incoming) settings['review_assistant_accounts'][ACCOUNT]['enabled'] = False assert not service.ensure_review(state, context(incoming)) assert not service.reconcile(state, context(incoming, message(2, True))) assert service.cancel_review(state) assert settings['review_assistant_accounts'][ACCOUNT]['enabled'] is False settings['review_assistant_accounts'][ACCOUNT]['enabled'] = True old_id = state['assistant_review_id'] assert service.ensure_review(state, context(incoming)) == old_id assert episode(service)['status'] == 'cancelled' assert not service.resumed(CONV) and not service.has_pending(CONV) def test_assistant_reply_and_notification_copy_do_not_finish_customer_task(tmp_path): service, state, _ = make_service(tmp_path) incoming = message() register(service, state, incoming) assistant = message(2, True, conv_id=ASSISTANT) assert not service.reconcile(state, dict(context(assistant), conv_id=ASSISTANT)) copied = message(2, True, content='【客户需人工处理】#old-copy') assert not service.reconcile(state, context(incoming, copied)) assert service.has_pending(CONV)