"""My Customers recipient tests: synthetic databases, no native calls or sends.""" import contextlib import json import os from pathlib import Path import shutil import sqlite3 import tempfile import unittest from unittest import mock import review_assistant_contacts as contacts ACCOUNT = '168800000000001' ACTIVE = {'ready': True, 'account_id': ACCOUNT, 'account_name': 'Test', 'reason': ''} S_TARGET = f'S:{ACCOUNT}_101' class ContactDirectoryTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.folder = self.root / ACCOUNT self.folder.mkdir() self.sql('message.db', '''CREATE TABLE message_table(conversation_id TEXT,send_time REAL,content TEXT); INSERT INTO message_table VALUES('S:168800000000001_101',1700000000,'MESSAGE_BODY_NOT_READ'); INSERT INTO message_table VALUES('M:102',1700000020,'SECOND_BODY_NOT_READ'); INSERT INTO message_table VALUES('M:999',1700000030,'private_without_customer_relation'); INSERT INTO message_table VALUES('S:168800000000001_888',1700000040,'internal'); INSERT INTO message_table VALUES('S:101_222',1700000050,'other_account'); INSERT INTO message_table VALUES('R:101',1700000060,'group');''') self.sql('user.db', '''CREATE TABLE user_table(id TEXT PRIMARY KEY,name TEXT,real_name TEXT,account TEXT,corp_id INTEGER,mobile TEXT); INSERT INTO user_table VALUES('168800000000001','托管账号','','',10,''); INSERT INTO user_table VALUES('101','助理昵称','真实名字','',20,'PHONE_NOT_READ'); INSERT INTO user_table VALUES('102','同名助理','','',30,''); INSERT INTO user_table VALUES('103','同名助理','','',30,''); INSERT INTO user_table VALUES('888','内部同事','','',10,''); CREATE TABLE external_user_relation_v3(user_id TEXT PRIMARY KEY,status INTEGER,stranger_type INTEGER,add_customer_time INTEGER,remarks TEXT,real_remarks TEXT); INSERT INTO external_user_relation_v3 VALUES('101',2049,0,1600000000,'助理备注','真实备注'); INSERT INTO external_user_relation_v3 VALUES('102',2057,0,1600000000,'',''); INSERT INTO external_user_relation_v3 VALUES('103',2049,0,1600000000,'',''); CREATE TABLE wechat_contactV1(wxid TEXT,gene INTEGER,name TEXT); INSERT INTO wechat_contactV1 VALUES('wxid_one',101,'手机通讯录姓名'); INSERT INTO wechat_contactV1 VALUES('wxid_two',999,'手机通讯录姓名'); CREATE TABLE wx_friend(user_id TEXT,nick_name TEXT,wx_id TEXT); INSERT INTO wx_friend VALUES('999','私人好友','wxid_two'); CREATE TABLE external_user_ids(value INTEGER); INSERT INTO external_user_ids VALUES(999); CREATE TABLE delete_external_userV1(user_id TEXT,status INTEGER); CREATE TABLE blacklist_external_userids(value TEXT); CREATE TABLE user_dept_tableV2(user_id TEXT); INSERT INTO user_dept_tableV2 VALUES('888');''') self.sql('session.db', '''CREATE TABLE conversation_table(id TEXT,name TEXT,roomname_remark TEXT,is_blocked INTEGER); INSERT INTO conversation_table VALUES('S:168800000000001_101','旧会话名','旧会话备注',0);''') self.patch = mock.patch.object(contacts, 'active_context', return_value=ACTIVE) self.identity = self.patch.start() self.addCleanup(self.patch.stop) contacts._CACHE.clear() def sql(self, filename, sql): with contextlib.closing(sqlite3.connect(self.folder / filename)) as db, db: db.executescript(sql) def write_user(self, sql): self.sql('user.db', sql) def listing(self, *args, **kwargs): return contacts.list_recipients(ACCOUNT, *args, cache_roots=[self.root], **kwargs) def validate(self, target): return contacts.validate_recipient(ACCOUNT, target, cache_roots=[self.root]) def test_only_my_customer_relationships_are_listed_not_phone_contacts_or_arbitrary_chats(self): result = self.listing() self.assertTrue(result['ready'], result) self.assertEqual((result['total'], result['selectable_total'], result['contact_total']), (3, 2, 1)) self.assertEqual({i['id'] for i in result['items']}, {S_TARGET, 'M:102', 'customer:103'}) self.assertNotIn('MESSAGE_BODY_NOT_READ', json.dumps(result)) self.assertEqual(self.listing('手机通讯录')['total'], 0) self.assertEqual(self.listing('私人好友')['total'], 0) self.assertFalse(self.validate('M:999')['ok']) def test_existing_wecom_and_wechat_targets_keep_exact_identity_and_kind(self): for target, peer, kind in [(S_TARGET, '101', 'wecom_customer'), ('M:102', '102', 'wechat_customer')]: result = self.validate(target) self.assertTrue(result['ok'], result) self.assertEqual(result['recipient']['id'], target) self.assertEqual(result['recipient']['peer_id'], peer) self.assertEqual(result['recipient']['recipient_kind'], kind) def test_both_s_id_orders_are_supported_but_no_address_is_synthesized(self): reverse = f'S:101_{ACCOUNT}' self.sql('message.db', f"UPDATE message_table SET conversation_id='{reverse}' WHERE conversation_id='{S_TARGET}';") self.assertTrue(self.validate(reverse)['ok']) self.assertFalse(self.validate(S_TARGET)['ok']) self.assertFalse(self.validate('M:101')['ok']) self.assertFalse(self.validate(f'S:{ACCOUNT}_103')['ok']) def test_no_conversation_customer_is_visible_but_cannot_be_selected(self): item = self.listing('103')['items'][0] self.assertEqual(item['id'], 'customer:103') self.assertFalse(item['selectable']) self.assertIn('建立单聊', item['unavailableReason']) self.assertFalse(self.validate(item['id'])['ok']) def test_customer_remarks_precede_true_remarks_and_user_names_and_all_are_searchable(self): for query in ['助理备注', '真实备注', '助理昵称', '真实名字', S_TARGET, '101']: result = self.listing(query) self.assertEqual(result['items'][0]['id'], S_TARGET, query) self.assertEqual(result['items'][0]['name'], '助理备注') self.assertEqual(self.listing('旧会话')['total'], 0) self.write_user("UPDATE external_user_relation_v3 SET remarks='' WHERE user_id='101';") self.assertEqual(self.listing('101')['items'][0]['name'], '真实备注') self.write_user("UPDATE external_user_relation_v3 SET real_remarks='' WHERE user_id='101';") self.assertEqual(self.listing('101')['items'][0]['name'], '助理昵称') def test_same_name_customers_remain_separate_and_pagination_is_after_filter(self): first = self.listing('同名助理', limit=1) second = self.listing('同名助理', limit=1, offset=1) self.assertEqual(first['total'], 2) self.assertTrue(first['has_more']) self.assertNotEqual(first['items'][0]['id'], second['items'][0]['id']) self.assertFalse(second['has_more']) pending = self.listing('同名助理', status='directory', limit=1) self.assertEqual(pending['total'], 1) self.assertEqual(pending['all_total'], 2) self.assertEqual(pending['items'][0]['peer_id'], '103') def test_unknown_relation_status_is_visible_but_cannot_be_selected(self): for value in (0, 1, 2048, 2059, -1, 'unknown'): with self.subTest(status=value): with contextlib.closing(sqlite3.connect(self.folder / 'user.db')) as db, db: db.execute('UPDATE external_user_relation_v3 SET status=? WHERE user_id=?', (value, '101')) item = self.listing('101')['items'][0] self.assertEqual(item['id'], 'customer:101') self.assertFalse(item['selectable']) self.assertIn('状态尚未核验', item['unavailableReason']) self.assertFalse(self.validate(S_TARGET)['ok']) def test_deleted_blacklisted_and_internal_users_are_excluded(self): for table, sql in [('delete_external_userV1', "INSERT INTO delete_external_userV1 VALUES('101',0);"), ('blacklist_external_userids', "INSERT INTO blacklist_external_userids VALUES('101');"), ('user_dept_tableV2', "INSERT INTO user_dept_tableV2 VALUES('101');")]: with self.subTest(table=table): self.write_user(sql) self.assertEqual(self.listing('101')['total'], 0) self.assertFalse(self.validate(S_TARGET)['ok']) self.write_user(f"DELETE FROM {table} WHERE {'value' if table=='blacklist_external_userids' else 'user_id'}='101';") self.write_user("UPDATE user_table SET corp_id=10 WHERE id='101';") self.assertEqual(self.listing('101')['total'], 0) def test_strangers_unadded_self_and_invalid_uids_are_not_my_customers(self): self.write_user("UPDATE external_user_relation_v3 SET stranger_type=1 WHERE user_id='101';UPDATE external_user_relation_v3 SET add_customer_time=0 WHERE user_id='102';") self.write_user(f"INSERT INTO external_user_relation_v3 VALUES('{ACCOUNT}',2049,0,1,'本人','');INSERT INTO external_user_relation_v3 VALUES('invalid',2049,0,1,'非法','');") self.assertEqual({i['peer_id'] for i in self.listing()['items']}, {'103'}) self.assertFalse(self.validate(S_TARGET)['ok']) def test_system_customer_names_and_session_metadata_cannot_enable_sends(self): for name in ('企小码会话管理', '企业微信团队', '微信团队'): self.write_user(f"UPDATE external_user_relation_v3 SET remarks='{name}' WHERE user_id='101';") self.assertEqual(self.listing('101')['total'], 0) self.write_user("UPDATE external_user_relation_v3 SET remarks='正常助理' WHERE user_id='101';") self.sql('session.db', "UPDATE conversation_table SET name='企小码会话管理';") self.assertFalse(self.listing('101')['items'][0]['selectable']) self.assertFalse(self.validate(S_TARGET)['ok']) def test_blocked_session_is_not_a_selectable_target(self): self.sql('session.db', 'UPDATE conversation_table SET is_blocked=1;') self.assertFalse(self.listing('101')['items'][0]['selectable']) self.assertFalse(self.validate(S_TARGET)['ok']) def test_unknown_name_does_not_become_fake_nickname_or_raw_s_identifier(self): self.write_user("UPDATE external_user_relation_v3 SET remarks='',real_remarks='' WHERE user_id='101';UPDATE user_table SET name='',real_name='' WHERE id='101';") item = self.listing('101')['items'][0] self.assertFalse(item['nameKnown']) self.assertEqual(item['displayName'], '未读取昵称 · 尾号 101') self.assertEqual(item['id'], S_TARGET) def test_missing_or_incomplete_customer_relation_table_fails_closed(self): self.write_user('DROP TABLE external_user_relation_v3;') for setup in ('', 'CREATE TABLE external_user_relation_v3(user_id TEXT,remarks TEXT);'): if setup: self.write_user(setup) result = self.listing() self.assertFalse(result['ready']) self.assertIn('关系表缺失或不完整', result['reason']) self.assertEqual(result['items'], []) self.assertFalse(self.validate(S_TARGET)['ok']) def test_missing_user_database_returns_clear_failure_not_keyerror(self): (self.folder / 'user.db').unlink() result = self.listing() self.assertFalse(result['ready']) self.assertIn('客户关系库尚不可读', result['reason']) self.assertFalse(self.validate(S_TARGET)['ok']) def test_no_message_cache_lists_customers_pending_without_fake_targets(self): (self.folder / 'message.db').unlink() result = self.listing() self.assertTrue(result['ready']) self.assertEqual(result['selectable_total'], 0) self.assertEqual(result['total'], 3) self.assertFalse(self.validate(S_TARGET)['ok']) def test_absent_optional_phone_tables_do_not_affect_customer_list(self): self.write_user('DROP TABLE wechat_contactV1;DROP TABLE wx_friend;DROP TABLE external_user_ids;') self.assertEqual(self.listing()['total'], 3) self.assertTrue(self.validate(S_TARGET)['ok']) def test_fresh_validation_bypasses_cached_membership_and_rechecks_account(self): with mock.patch.object(contacts, '_revision', return_value=('fixed',)): self.assertTrue(self.validate(S_TARGET)['ok']) self.write_user("DELETE FROM external_user_relation_v3 WHERE user_id='101';") self.assertFalse(self.validate(S_TARGET)['ok']) self.identity.side_effect = [ACTIVE, {**ACTIVE, 'account_id': 'another-account'}] self.assertFalse(self.validate('M:102')['ok']) def test_refresh_invalidates_cache_for_new_customer_relationship(self): self.assertEqual(self.listing()['total'], 3) self.write_user("INSERT INTO external_user_relation_v3 VALUES('104',2049,0,1,'新客户','');") self.assertEqual(self.listing('新客户')['items'][0]['id'], 'customer:104') def test_wal_change_invalidates_customer_relationship_cache(self): with contextlib.closing(sqlite3.connect(self.folder / 'user.db')) as db: db.execute('PRAGMA journal_mode=WAL') db.execute('PRAGMA wal_autocheckpoint=0') self.assertEqual(self.listing()['total'], 3) db.execute("UPDATE external_user_relation_v3 SET status=0 WHERE user_id='101'") db.commit() self.assertFalse(self.listing('101')['items'][0]['selectable']) self.assertFalse(self.validate(S_TARGET)['ok']) def test_latest_wal_revision_wins_between_account_caches(self): other = self.root / 'other' / ACCOUNT other.mkdir(parents=True) shutil.copyfile(self.folder / 'message.db', other / 'message.db') shutil.copyfile(self.folder / 'user.db', other / 'user.db') os.utime(self.folder / 'user.db', ns=(100, 100)) os.utime(other / 'user.db', ns=(200, 200)) wal = Path(str(self.folder / 'user.db') + '-wal') wal.write_bytes(b'file-time-only') os.utime(wal, ns=(300, 300)) self.assertEqual(contacts._databases(ACCOUNT, [self.root, self.root / 'other'])['user.db'], self.folder / 'user.db') def test_default_roots_include_protocol_cache_before_archived_copy(self): with mock.patch.object(contacts, 'application_data_dir', return_value=self.root): self.assertEqual(contacts._roots()[0], self.root / 'wxwork_reply_cache') def test_other_account_directory_is_never_read(self): self.identity.return_value = {**ACTIVE, 'account_id': '168800000000002'} with mock.patch.object(contacts, '_contacts') as read: self.assertFalse(self.listing()['ready']) read.assert_not_called() def test_lookup_does_not_change_database_files_or_read_sensitive_columns(self): before = {p.name: p.read_bytes() for p in self.folder.glob('*.db')} original = contacts._open def guarded(path): db = original(path) def authorize(operation, table, field, database, source): if operation == sqlite3.SQLITE_READ and (field in {'content', 'mobile', 'phone', 'pb_content'} or table in {'wechat_contactV1', 'wx_friend'}): return sqlite3.SQLITE_DENY return sqlite3.SQLITE_OK db.set_authorizer(authorize) return db with mock.patch.object(contacts, '_open', side_effect=guarded): self.assertTrue(self.listing()['ready']) self.assertTrue(self.validate(S_TARGET)['ok']) self.assertEqual(before, {p.name: p.read_bytes() for p in self.folder.glob('*.db')}) class RecipientPeerTest(unittest.TestCase): def test_only_exact_account_bound_direct_ids_are_accepted(self): self.assertEqual(contacts.recipient_peer(ACCOUNT, 'M:101'), '101') self.assertEqual(contacts.recipient_peer(ACCOUNT, S_TARGET), '101') self.assertEqual(contacts.recipient_peer(ACCOUNT, f'S:101_{ACCOUNT}'), '101') for value in ('', ' M:101', 'M:101 ', 'M:123', 'M:abc', 'M:1_2', f'M:{ACCOUNT}', f'S:{ACCOUNT}_{ACCOUNT}', 'S:101_102', f'S:{ACCOUNT}_101_102', f'S:{ACCOUNT}_', 'R:101', 'O:101', 'Y:101', 'APPROVAL', 'customer:101'): with self.subTest(value=value): self.assertEqual(contacts.recipient_peer(ACCOUNT, value), '') self.assertEqual(contacts.recipient_peer('', 'M:101'), '') if __name__ == '__main__': unittest.main()