Skip to content

Commit cf58d26

Browse files
committed
test(chat): 覆盖系统撤回/群名片/实时会话同步相关用例
- 新增系统撤回消息 replacemsg 解析与导出语义测试 - 新增群聊会话预览格式化与群名片 ext_buffer 解析测试 - 新增 realtime 会话列表与 sync_all 落库 last_sender_display_name 测试
1 parent 2c832aa commit cf58d26

6 files changed

Lines changed: 479 additions & 0 deletions

tests/test_chat_export_message_types_semantics.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ def _seed_message_db(self, path: Path, *, account: str, username: str) -> None:
122122
(3, 1003, 49, 3, 2, 1735689603, '<msg><appmsg><type>2000</type><des>收到转账0.01元</des></appmsg></msg>', None),
123123
(4, 1004, 1, 4, 2, 1735689604, '普通文本消息', None),
124124
(5, 1005, 10000, 5, 2, 1735689605, '系统提示消息', None),
125+
(
126+
6,
127+
1006,
128+
10000,
129+
6,
130+
2,
131+
1735689606,
132+
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
133+
None,
134+
),
125135
]
126136
conn.executemany(
127137
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 @@ def test_transfer_only_exports_transfer_messages(self):
413423
else:
414424
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
415425

426+
def test_system_revoke_exports_readable_revoker_content(self):
427+
with TemporaryDirectory() as td:
428+
root = Path(td)
429+
account = "wxid_test"
430+
username = "wxid_friend"
431+
self._prepare_account(root, account=account, username=username)
432+
433+
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
434+
try:
435+
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
436+
svc = self._reload_export_modules()
437+
job = self._create_job(
438+
svc.CHAT_EXPORT_MANAGER,
439+
account=account,
440+
username=username,
441+
message_types=["system"],
442+
include_media=False,
443+
)
444+
self.assertEqual(job.status, "done", msg=job.error)
445+
446+
payload, _, _ = self._load_export_payload(job.zip_path)
447+
revoke_msg = next((m for m in payload.get("messages", []) if int(m.get("serverId") or 0) == 1006), None)
448+
self.assertIsNotNone(revoke_msg)
449+
self.assertEqual(str(revoke_msg.get("renderType") or ""), "system")
450+
self.assertEqual(str(revoke_msg.get("content") or ""), "“测试好友”撤回了一条消息")
451+
finally:
452+
if prev_data is None:
453+
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
454+
else:
455+
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
456+
416457

417458
if __name__ == "__main__":
418459
unittest.main()
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import sqlite3
2+
import sys
3+
import threading
4+
import unittest
5+
from pathlib import Path
6+
from tempfile import TemporaryDirectory
7+
from unittest.mock import patch
8+
9+
10+
ROOT = Path(__file__).resolve().parents[1]
11+
sys.path.insert(0, str(ROOT / "src"))
12+
13+
from wechat_decrypt_tool.routers import chat as chat_router
14+
15+
16+
class _DummyRequest:
17+
base_url = "http://testserver/"
18+
19+
20+
class _DummyConn:
21+
def __init__(self) -> None:
22+
self.handle = 1
23+
self.lock = threading.Lock()
24+
25+
26+
def _seed_session_db(session_db_path: Path) -> None:
27+
conn = sqlite3.connect(str(session_db_path))
28+
try:
29+
conn.execute(
30+
"""
31+
CREATE TABLE SessionTable (
32+
username TEXT PRIMARY KEY,
33+
unread_count INTEGER DEFAULT 0,
34+
is_hidden INTEGER DEFAULT 0,
35+
summary TEXT DEFAULT '',
36+
draft TEXT DEFAULT '',
37+
last_timestamp INTEGER DEFAULT 0,
38+
sort_timestamp INTEGER DEFAULT 0,
39+
last_msg_locald_id INTEGER DEFAULT 0,
40+
last_msg_type INTEGER DEFAULT 0,
41+
last_msg_sub_type INTEGER DEFAULT 0,
42+
last_msg_sender TEXT DEFAULT '',
43+
last_sender_display_name TEXT DEFAULT ''
44+
)
45+
"""
46+
)
47+
conn.commit()
48+
finally:
49+
conn.close()
50+
51+
52+
class TestChatRealtimeSyncAllUpdatesSenderDisplayName(unittest.TestCase):
53+
def test_sync_all_upserts_last_sender_display_name(self):
54+
with TemporaryDirectory() as td:
55+
account_dir = Path(td) / "acc"
56+
account_dir.mkdir(parents=True, exist_ok=True)
57+
_seed_session_db(account_dir / "session.db")
58+
59+
conn = _DummyConn()
60+
sessions_rows = [
61+
{
62+
"username": "demo@chatroom",
63+
"unread_count": 0,
64+
"is_hidden": 0,
65+
"summary": "hello",
66+
"draft": "",
67+
"last_timestamp": 123,
68+
"sort_timestamp": 123,
69+
"last_msg_type": 1,
70+
"last_msg_sub_type": 0,
71+
"last_msg_sender": "wxid_demo",
72+
"last_sender_display_name": "群名片A",
73+
"last_msg_locald_id": 777,
74+
}
75+
]
76+
77+
with (
78+
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
79+
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
80+
patch.object(chat_router, "_wcdb_get_sessions", return_value=sessions_rows),
81+
patch.object(chat_router, "_ensure_decrypted_message_tables", return_value={}),
82+
patch.object(chat_router, "_should_keep_session", return_value=True),
83+
):
84+
resp = chat_router.sync_chat_realtime_messages_all(
85+
_DummyRequest(),
86+
account="acc",
87+
max_scan=20,
88+
include_hidden=True,
89+
include_official=True,
90+
)
91+
92+
self.assertEqual(resp.get("status"), "success")
93+
94+
db = sqlite3.connect(str(account_dir / "session.db"))
95+
try:
96+
row = db.execute(
97+
"SELECT last_sender_display_name, last_msg_sender, last_msg_locald_id FROM SessionTable WHERE username = ? LIMIT 1",
98+
("demo@chatroom",),
99+
).fetchone()
100+
finally:
101+
db.close()
102+
103+
self.assertIsNotNone(row)
104+
self.assertEqual(str(row[0] or ""), "群名片A")
105+
self.assertEqual(str(row[1] or ""), "wxid_demo")
106+
self.assertEqual(int(row[2] or 0), 777)
107+
108+
109+
if __name__ == "__main__":
110+
unittest.main()
111+
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import sqlite3
2+
import sys
3+
import unittest
4+
from pathlib import Path
5+
from tempfile import TemporaryDirectory
6+
7+
8+
ROOT = Path(__file__).resolve().parents[1]
9+
sys.path.insert(0, str(ROOT / "src"))
10+
11+
from wechat_decrypt_tool.chat_helpers import (
12+
_build_group_sender_display_name_map,
13+
_normalize_session_preview_text,
14+
_replace_preview_sender_prefix,
15+
)
16+
17+
18+
class TestChatSessionPreviewFormatting(unittest.TestCase):
19+
def test_normalize_session_preview_emoji_label(self):
20+
out = _normalize_session_preview_text("[表情]", is_group=False, sender_display_names={})
21+
self.assertEqual(out, "[动画表情]")
22+
23+
def test_normalize_group_preview_sender_display_name(self):
24+
out = _normalize_session_preview_text(
25+
"wxid_u3gwceqvne2m22: [表情]",
26+
is_group=True,
27+
sender_display_names={"wxid_u3gwceqvne2m22": "食神"},
28+
)
29+
self.assertEqual(out, "食神: [动画表情]")
30+
31+
def test_build_group_sender_display_name_map_from_contact_db(self):
32+
with TemporaryDirectory() as td:
33+
contact_db_path = Path(td) / "contact.db"
34+
conn = sqlite3.connect(str(contact_db_path))
35+
try:
36+
conn.execute(
37+
"""
38+
CREATE TABLE contact (
39+
username TEXT,
40+
remark TEXT,
41+
nick_name TEXT,
42+
alias TEXT,
43+
big_head_url TEXT,
44+
small_head_url TEXT
45+
)
46+
"""
47+
)
48+
conn.execute(
49+
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?)",
50+
("wxid_u3gwceqvne2m22", "", "食神", "", "", ""),
51+
)
52+
conn.commit()
53+
finally:
54+
conn.close()
55+
56+
mapping = _build_group_sender_display_name_map(
57+
contact_db_path,
58+
{"demo@chatroom": "wxid_u3gwceqvne2m22: [动画表情]"},
59+
)
60+
self.assertEqual(mapping.get("wxid_u3gwceqvne2m22"), "食神")
61+
62+
def test_replace_preview_sender_prefix_uses_group_nickname(self):
63+
out = _replace_preview_sender_prefix("去码头整点🍟: [动画表情]", "麻辣香锅")
64+
self.assertEqual(out, "麻辣香锅: [动画表情]")
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import sys
2+
import threading
3+
import unittest
4+
from pathlib import Path
5+
from tempfile import TemporaryDirectory
6+
from unittest.mock import patch
7+
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
sys.path.insert(0, str(ROOT / "src"))
11+
12+
13+
from wechat_decrypt_tool.routers import chat as chat_router
14+
15+
16+
class _DummyRequest:
17+
base_url = "http://testserver/"
18+
19+
20+
class _DummyConn:
21+
def __init__(self) -> None:
22+
self.handle = 1
23+
self.lock = threading.Lock()
24+
25+
26+
class TestChatSessionsRealtimeSenderPreview(unittest.TestCase):
27+
def _run(self, sessions_rows: list[dict]) -> dict:
28+
with TemporaryDirectory() as td:
29+
account_dir = Path(td) / "acc"
30+
account_dir.mkdir(parents=True, exist_ok=True)
31+
32+
conn = _DummyConn()
33+
with (
34+
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
35+
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
36+
patch.object(chat_router, "_wcdb_get_sessions", return_value=sessions_rows),
37+
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
38+
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
39+
patch.object(chat_router, "_load_contact_rows", return_value={}),
40+
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
41+
patch.object(chat_router, "_should_keep_session", return_value=True),
42+
patch.object(chat_router, "_avatar_url_unified", return_value="/avatar"),
43+
):
44+
return chat_router.list_chat_sessions(
45+
_DummyRequest(),
46+
account="acc",
47+
limit=50,
48+
include_hidden=True,
49+
include_official=True,
50+
preview="latest",
51+
source="realtime",
52+
)
53+
54+
def test_realtime_sessions_group_summary_prefixed_by_sender_display_name(self):
55+
resp = self._run(
56+
[
57+
{
58+
"username": "demo@chatroom",
59+
"summary": "hello",
60+
"draft": "",
61+
"unread_count": 0,
62+
"is_hidden": 0,
63+
"last_timestamp": 123,
64+
"sort_timestamp": 123,
65+
"last_msg_type": 1,
66+
"last_msg_sub_type": 0,
67+
"last_msg_sender": "wxid_demo",
68+
"last_sender_display_name": "群名片A",
69+
}
70+
]
71+
)
72+
self.assertEqual(resp.get("status"), "success")
73+
sessions = resp.get("sessions") or []
74+
self.assertEqual(len(sessions), 1)
75+
self.assertEqual(sessions[0].get("lastMessage"), "群名片A: hello")
76+
77+
def test_realtime_sessions_group_url_summary_keeps_scheme(self):
78+
resp = self._run(
79+
[
80+
{
81+
"username": "url@chatroom",
82+
"summary": "https://example.com/x",
83+
"draft": "",
84+
"unread_count": 0,
85+
"is_hidden": 0,
86+
"last_timestamp": 123,
87+
"sort_timestamp": 123,
88+
"last_msg_type": 1,
89+
"last_msg_sub_type": 0,
90+
"last_msg_sender": "wxid_demo",
91+
"last_sender_display_name": "群名片B",
92+
}
93+
]
94+
)
95+
self.assertEqual(resp.get("status"), "success")
96+
sessions = resp.get("sessions") or []
97+
self.assertEqual(len(sessions), 1)
98+
self.assertEqual(sessions[0].get("lastMessage"), "群名片B: https://example.com/x")
99+
100+
101+
if __name__ == "__main__":
102+
unittest.main()
103+
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import sys
2+
import unittest
3+
from pathlib import Path
4+
5+
6+
ROOT = Path(__file__).resolve().parents[1]
7+
sys.path.insert(0, str(ROOT / "src"))
8+
9+
from wechat_decrypt_tool.chat_helpers import _parse_system_message_content
10+
11+
12+
class TestChatSystemMessageParsing(unittest.TestCase):
13+
def test_extract_replacemsg_for_revoke(self):
14+
raw_text = (
15+
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“张三”撤回了一条消息]]>'
16+
"</replacemsg></revokemsg></sysmsg>"
17+
)
18+
self.assertEqual(_parse_system_message_content(raw_text), "“张三”撤回了一条消息")
19+
20+
def test_extract_nested_content_in_replacemsg(self):
21+
raw_text = (
22+
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA['
23+
'<content>"黄智欢" 撤回了一条消息</content><revoketime>0</revoketime>'
24+
']]></replacemsg></revokemsg></sysmsg>'
25+
)
26+
self.assertEqual(_parse_system_message_content(raw_text), '"黄智欢" 撤回了一条消息')
27+
28+
def test_extract_revokemsg_text_when_replacemsg_missing(self):
29+
raw_text = "<revokemsg>你撤回了一条消息</revokemsg>"
30+
self.assertEqual(_parse_system_message_content(raw_text), "你撤回了一条消息")
31+
32+
def test_revoke_fallback_when_no_readable_text(self):
33+
raw_text = '<sysmsg type="revokemsg"></sysmsg>'
34+
self.assertEqual(_parse_system_message_content(raw_text), "撤回了一条消息")
35+
36+
def test_normal_system_message_still_cleaned(self):
37+
raw_text = "<sysmsg><template><![CDATA[ 张三 加入了群聊 ]]></template></sysmsg>"
38+
self.assertEqual(_parse_system_message_content(raw_text), "张三 加入了群聊")
39+
40+
41+
if __name__ == "__main__":
42+
unittest.main()

0 commit comments

Comments
 (0)