mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
test(chat): 覆盖系统撤回/群名片/实时会话同步相关用例
- 新增系统撤回消息 replacemsg 解析与导出语义测试 - 新增群聊会话预览格式化与群名片 ext_buffer 解析测试 - 新增 realtime 会话列表与 sync_all 落库 last_sender_display_name 测试
This commit is contained in:
@@ -122,6 +122,16 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
(3, 1003, 49, 3, 2, 1735689603, '<msg><appmsg><type>2000</type><des>收到转账0.01元</des></appmsg></msg>', None),
|
||||
(4, 1004, 1, 4, 2, 1735689604, '普通文本消息', None),
|
||||
(5, 1005, 10000, 5, 2, 1735689605, '系统提示消息', None),
|
||||
(
|
||||
6,
|
||||
1006,
|
||||
10000,
|
||||
6,
|
||||
2,
|
||||
1735689606,
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
|
||||
None,
|
||||
),
|
||||
]
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -413,6 +423,37 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_system_revoke_exports_readable_revoker_content(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["system"],
|
||||
include_media=False,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, _ = self._load_export_payload(job.zip_path)
|
||||
revoke_msg = next((m for m in payload.get("messages", []) if int(m.get("serverId") or 0) == 1006), None)
|
||||
self.assertIsNotNone(revoke_msg)
|
||||
self.assertEqual(str(revoke_msg.get("renderType") or ""), "system")
|
||||
self.assertEqual(str(revoke_msg.get("content") or ""), "“测试好友”撤回了一条消息")
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
111
tests/test_chat_realtime_sync_all_updates_sender_display_name.py
Normal file
111
tests/test_chat_realtime_sync_all_updates_sender_display_name.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
def _seed_session_db(session_db_path: Path) -> None:
|
||||
conn = sqlite3.connect(str(session_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT PRIMARY KEY,
|
||||
unread_count INTEGER DEFAULT 0,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
summary TEXT DEFAULT '',
|
||||
draft TEXT DEFAULT '',
|
||||
last_timestamp INTEGER DEFAULT 0,
|
||||
sort_timestamp INTEGER DEFAULT 0,
|
||||
last_msg_locald_id INTEGER DEFAULT 0,
|
||||
last_msg_type INTEGER DEFAULT 0,
|
||||
last_msg_sub_type INTEGER DEFAULT 0,
|
||||
last_msg_sender TEXT DEFAULT '',
|
||||
last_sender_display_name TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestChatRealtimeSyncAllUpdatesSenderDisplayName(unittest.TestCase):
|
||||
def test_sync_all_upserts_last_sender_display_name(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
_seed_session_db(account_dir / "session.db")
|
||||
|
||||
conn = _DummyConn()
|
||||
sessions_rows = [
|
||||
{
|
||||
"username": "demo@chatroom",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"summary": "hello",
|
||||
"draft": "",
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片A",
|
||||
"last_msg_locald_id": 777,
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
|
||||
patch.object(chat_router, "_wcdb_get_sessions", return_value=sessions_rows),
|
||||
patch.object(chat_router, "_ensure_decrypted_message_tables", return_value={}),
|
||||
patch.object(chat_router, "_should_keep_session", return_value=True),
|
||||
):
|
||||
resp = chat_router.sync_chat_realtime_messages_all(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
max_scan=20,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
|
||||
db = sqlite3.connect(str(account_dir / "session.db"))
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT last_sender_display_name, last_msg_sender, last_msg_locald_id FROM SessionTable WHERE username = ? LIMIT 1",
|
||||
("demo@chatroom",),
|
||||
).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(str(row[0] or ""), "群名片A")
|
||||
self.assertEqual(str(row[1] or ""), "wxid_demo")
|
||||
self.assertEqual(int(row[2] or 0), 777)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
68
tests/test_chat_session_preview_formatting.py
Normal file
68
tests/test_chat_session_preview_formatting.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import (
|
||||
_build_group_sender_display_name_map,
|
||||
_normalize_session_preview_text,
|
||||
_replace_preview_sender_prefix,
|
||||
)
|
||||
|
||||
|
||||
class TestChatSessionPreviewFormatting(unittest.TestCase):
|
||||
def test_normalize_session_preview_emoji_label(self):
|
||||
out = _normalize_session_preview_text("[表情]", is_group=False, sender_display_names={})
|
||||
self.assertEqual(out, "[动画表情]")
|
||||
|
||||
def test_normalize_group_preview_sender_display_name(self):
|
||||
out = _normalize_session_preview_text(
|
||||
"wxid_u3gwceqvne2m22: [表情]",
|
||||
is_group=True,
|
||||
sender_display_names={"wxid_u3gwceqvne2m22": "食神"},
|
||||
)
|
||||
self.assertEqual(out, "食神: [动画表情]")
|
||||
|
||||
def test_build_group_sender_display_name_map_from_contact_db(self):
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("wxid_u3gwceqvne2m22", "", "食神", "", "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
mapping = _build_group_sender_display_name_map(
|
||||
contact_db_path,
|
||||
{"demo@chatroom": "wxid_u3gwceqvne2m22: [动画表情]"},
|
||||
)
|
||||
self.assertEqual(mapping.get("wxid_u3gwceqvne2m22"), "食神")
|
||||
|
||||
def test_replace_preview_sender_prefix_uses_group_nickname(self):
|
||||
out = _replace_preview_sender_prefix("去码头整点🍟: [动画表情]", "麻辣香锅")
|
||||
self.assertEqual(out, "麻辣香锅: [动画表情]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
103
tests/test_chat_sessions_realtime_sender_preview.py
Normal file
103
tests/test_chat_sessions_realtime_sender_preview.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
class TestChatSessionsRealtimeSenderPreview(unittest.TestCase):
|
||||
def _run(self, sessions_rows: list[dict]) -> dict:
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = _DummyConn()
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
|
||||
patch.object(chat_router, "_wcdb_get_sessions", return_value=sessions_rows),
|
||||
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
|
||||
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
|
||||
patch.object(chat_router, "_load_contact_rows", return_value={}),
|
||||
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
|
||||
patch.object(chat_router, "_should_keep_session", return_value=True),
|
||||
patch.object(chat_router, "_avatar_url_unified", return_value="/avatar"),
|
||||
):
|
||||
return chat_router.list_chat_sessions(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
limit=50,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
preview="latest",
|
||||
source="realtime",
|
||||
)
|
||||
|
||||
def test_realtime_sessions_group_summary_prefixed_by_sender_display_name(self):
|
||||
resp = self._run(
|
||||
[
|
||||
{
|
||||
"username": "demo@chatroom",
|
||||
"summary": "hello",
|
||||
"draft": "",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片A",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertEqual(sessions[0].get("lastMessage"), "群名片A: hello")
|
||||
|
||||
def test_realtime_sessions_group_url_summary_keeps_scheme(self):
|
||||
resp = self._run(
|
||||
[
|
||||
{
|
||||
"username": "url@chatroom",
|
||||
"summary": "https://example.com/x",
|
||||
"draft": "",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片B",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertEqual(sessions[0].get("lastMessage"), "群名片B: https://example.com/x")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
42
tests/test_chat_system_message_parsing.py
Normal file
42
tests/test_chat_system_message_parsing.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _parse_system_message_content
|
||||
|
||||
|
||||
class TestChatSystemMessageParsing(unittest.TestCase):
|
||||
def test_extract_replacemsg_for_revoke(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“张三”撤回了一条消息]]>'
|
||||
"</replacemsg></revokemsg></sysmsg>"
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "“张三”撤回了一条消息")
|
||||
|
||||
def test_extract_nested_content_in_replacemsg(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA['
|
||||
'<content>"黄智欢" 撤回了一条消息</content><revoketime>0</revoketime>'
|
||||
']]></replacemsg></revokemsg></sysmsg>'
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), '"黄智欢" 撤回了一条消息')
|
||||
|
||||
def test_extract_revokemsg_text_when_replacemsg_missing(self):
|
||||
raw_text = "<revokemsg>你撤回了一条消息</revokemsg>"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "你撤回了一条消息")
|
||||
|
||||
def test_revoke_fallback_when_no_readable_text(self):
|
||||
raw_text = '<sysmsg type="revokemsg"></sysmsg>'
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "撤回了一条消息")
|
||||
|
||||
def test_normal_system_message_still_cleaned(self):
|
||||
raw_text = "<sysmsg><template><![CDATA[ 张三 加入了群聊 ]]></template></sysmsg>"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "张三 加入了群聊")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
114
tests/test_group_nickname_ext_buffer_parsing.py
Normal file
114
tests/test_group_nickname_ext_buffer_parsing.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _load_group_nickname_map_from_contact_db
|
||||
|
||||
|
||||
def _enc_varint(n: int) -> bytes:
|
||||
v = int(n)
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = v & 0x7F
|
||||
v >>= 7
|
||||
if v:
|
||||
out.append(b | 0x80)
|
||||
else:
|
||||
out.append(b)
|
||||
break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _enc_tag(field_no: int, wire_type: int) -> bytes:
|
||||
return _enc_varint((int(field_no) << 3) | int(wire_type))
|
||||
|
||||
|
||||
def _enc_len(field_no: int, data: bytes) -> bytes:
|
||||
b = bytes(data or b"")
|
||||
return _enc_tag(field_no, 2) + _enc_varint(len(b)) + b
|
||||
|
||||
|
||||
def _member_entry(*, inner: bytes) -> bytes:
|
||||
# contact.db ext_buffer uses repeated length-delimited submessages; the top-level field number is not important
|
||||
# for our best-effort parser, so we use field 1.
|
||||
return _enc_len(1, inner)
|
||||
|
||||
|
||||
class TestGroupNicknameExtBufferParsing(unittest.TestCase):
|
||||
def test_parse_pattern_a_field1_username_field2_display(self):
|
||||
chatroom = "demo@chatroom"
|
||||
username = "wxid_demo_123456"
|
||||
display = "群名片A"
|
||||
|
||||
inner = _enc_len(1, username.encode("utf-8")) + _enc_len(2, display.encode("utf-8"))
|
||||
ext_buffer = _member_entry(inner=inner)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_room(id, username, owner, ext_buffer) VALUES (?, ?, ?, ?)",
|
||||
(1, chatroom, "", ext_buffer),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, chatroom, [username])
|
||||
self.assertEqual(out.get(username), display)
|
||||
|
||||
def test_parse_pattern_b_field4_username_field1_display(self):
|
||||
chatroom = "demo2@chatroom"
|
||||
username = "wxid_demo_abcdef"
|
||||
display = "hjlbingo"
|
||||
|
||||
inner = _enc_len(4, username.encode("utf-8")) + _enc_len(1, display.encode("utf-8"))
|
||||
ext_buffer = _member_entry(inner=inner)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_room(id, username, owner, ext_buffer) VALUES (?, ?, ?, ?)",
|
||||
(1, chatroom, "", ext_buffer),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, chatroom, [username])
|
||||
self.assertEqual(out.get(username), display)
|
||||
|
||||
def test_non_chatroom_returns_empty(self):
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, "wxid_not_chatroom", ["wxid_xxx"])
|
||||
self.assertEqual(out, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user