wechat-robot-skills/tests/test_export_chat_history.py

352 lines
10 KiB
Python

from __future__ import annotations
import argparse
import contextlib
import importlib.util
import io
import json
import os
import sys
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from unittest import mock
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
SCRIPT_PATH = (
REPOSITORY_ROOT
/ "skills"
/ "export-chat-history"
/ "scripts"
/ "export_chat_history.py"
)
def load_script():
module_name = "_test_export_chat_history"
spec = importlib.util.spec_from_file_location(module_name, SCRIPT_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"无法加载测试脚本:{SCRIPT_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
class FakeCursor:
def __init__(
self,
*,
fetchone_result=None,
fetchall_result=None,
) -> None:
self.fetchone_result = fetchone_result
self.fetchall_result = fetchall_result or []
self.sql = ""
self.params = ()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
return False
def execute(self, sql, params) -> None:
self.sql = sql
self.params = params
def fetchone(self):
return self.fetchone_result
def fetchall(self):
return self.fetchall_result
class FakeConnection:
def __init__(self, cursors: list[FakeCursor]) -> None:
self.cursors = list(cursors)
self.closed = False
def cursor(self):
return self.cursors.pop(0)
def close(self) -> None:
self.closed = True
class ExportChatHistoryTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.module = load_script()
def test_resolve_time_range_supports_date_and_today_default(self) -> None:
now = datetime(
2026,
7,
29,
15,
30,
tzinfo=self.module.SHANGHAI_TZ,
)
date_args = argparse.Namespace(
export_date="2026-07-28",
start_time="",
end_time="",
)
start, end = self.module._resolve_time_range(date_args, now)
self.assertEqual(start.isoformat(), "2026-07-28T00:00:00+08:00")
self.assertEqual(end.isoformat(), "2026-07-29T00:00:00+08:00")
default_args = argparse.Namespace(
export_date="",
start_time="",
end_time="",
)
start, end = self.module._resolve_time_range(default_args, now)
self.assertEqual(start.isoformat(), "2026-07-29T00:00:00+08:00")
self.assertEqual(end, now)
def test_parse_args_rejects_conflicting_or_partial_ranges(self) -> None:
with self.assertRaisesRegex(ValueError, "不能与"):
self.module._parse_args(
[
"--date",
"2026-07-29",
"--start_time",
"2026-07-29 09:00",
"--end_time",
"2026-07-29 10:00",
]
)
with self.assertRaisesRegex(ValueError, "必须同时提供"):
self.module._parse_args(
["--start_time", "2026-07-29 09:00"]
)
def test_query_matches_chat_room_summary_scope(self) -> None:
rows = [
{
"id": 1,
"sender_wxid": "member",
"nickname": "成员",
"message": "你好",
"created_at": 1_753_776_000,
}
]
cursor = FakeCursor(fetchall_result=rows)
connection = FakeConnection([cursor])
result = self.module._fetch_messages(
connection,
"group@chatroom",
"robot",
1_753_776_000,
1_753_862_400,
)
self.assertEqual(result, rows)
self.assertIn("LEFT JOIN chat_room_members", cursor.sql)
self.assertIn("messages.sender_wxid != %s", cursor.sql)
self.assertIn("EXTRACTVALUE", cursor.sql)
self.assertIn("messages.created_at >= %s", cursor.sql)
self.assertIn("messages.created_at < %s", cursor.sql)
self.assertEqual(
cursor.params,
(
"group@chatroom",
"robot",
1_753_776_000,
1_753_862_400,
self.module.MAX_EXPORT_ROWS + 1,
),
)
def test_excel_text_limit_includes_formula_protection_prefix(self) -> None:
raw = "=" + ("x" * self.module.EXCEL_CELL_MAX_LENGTH)
result = self.module._safe_excel_text(raw)
self.assertEqual(len(result), self.module.EXCEL_CELL_MAX_LENGTH)
self.assertTrue(result.startswith("'="))
self.assertTrue(result.endswith("……(内容过长,已截断)"))
@unittest.skipUnless(
importlib.util.find_spec("openpyxl"),
"openpyxl is not installed",
)
def test_workbook_contains_metadata_and_escapes_formula_text(self) -> None:
from openpyxl import load_workbook
start = datetime(
2026,
7,
29,
9,
0,
tzinfo=self.module.SHANGHAI_TZ,
)
end = datetime(
2026,
7,
29,
10,
0,
tzinfo=self.module.SHANGHAI_TZ,
)
messages = [
{
"id": 1,
"sender_wxid": "wxid_1",
"nickname": "@成员",
"message": "=HYPERLINK(\"https://example.test\")\x00",
"created_at": int(start.timestamp()),
}
]
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "history.xlsx"
self.module._build_workbook(
output,
"group@chatroom",
"测试群",
start,
end,
messages,
exported_at=end,
)
workbook = load_workbook(output, read_only=True, data_only=False)
self.assertEqual(workbook.sheetnames, ["导出说明", "聊天记录"])
info_rows = list(workbook["导出说明"].iter_rows(values_only=True))
self.assertIn(("群聊名称", "测试群"), info_rows)
history_rows = list(
workbook["聊天记录"].iter_rows(values_only=True)
)
self.assertEqual(
history_rows[0],
(
"序号",
"发送时间",
"发送人",
"发送人微信 ID",
"消息内容",
),
)
self.assertEqual(history_rows[1][2], "'@成员")
self.assertEqual(
history_rows[1][4],
"'=HYPERLINK(\"https://example.test\")",
)
workbook.close()
def test_main_sends_generated_file_and_removes_it_after_success(self) -> None:
start = datetime(
2026,
7,
29,
9,
0,
tzinfo=self.module.SHANGHAI_TZ,
)
end = datetime(
2026,
7,
29,
10,
0,
tzinfo=self.module.SHANGHAI_TZ,
)
messages = [
{
"id": 1,
"sender_wxid": "wxid_1",
"nickname": "成员",
"message": "你好",
"created_at": int(start.timestamp()),
}
]
connection = FakeConnection([])
stdout = io.StringIO()
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "history.xlsx"
def build_workbook(path, *args, **kwargs):
del args, kwargs
path.write_bytes(b"xlsx")
def send_file(client_port, chat_room_id, path):
self.assertEqual(client_port, "9100")
self.assertEqual(chat_room_id, "group@chatroom")
self.assertTrue(path.is_file())
patches = (
mock.patch.object(
self.module,
"_resolve_time_range",
return_value=(start, end),
),
mock.patch.object(
self.module,
"_ensure_runtime_dependencies",
),
mock.patch.object(
self.module,
"_mysql_connect",
return_value=connection,
),
mock.patch.object(
self.module,
"_fetch_chat_room_name",
return_value="测试群",
),
mock.patch.object(
self.module,
"_fetch_messages",
return_value=messages,
),
mock.patch.object(
self.module,
"_create_output_path",
return_value=output,
),
mock.patch.object(
self.module,
"_build_workbook",
side_effect=build_workbook,
),
mock.patch.object(
self.module,
"_send_file",
side_effect=send_file,
),
)
environment = {
"ROBOT_FROM_WX_ID": "group@chatroom",
"ROBOT_WX_ID": "robot",
"ROBOT_WECHAT_CLIENT_PORT": "9100",
}
with contextlib.ExitStack() as stack:
for patcher in patches:
stack.enter_context(patcher)
stack.enter_context(
mock.patch.dict(os.environ, environment, clear=False)
)
stack.enter_context(
mock.patch.object(sys, "argv", ["export_chat_history.py"])
)
with contextlib.redirect_stdout(stdout):
return_code = self.module.main()
self.assertEqual(return_code, 0)
self.assertTrue(connection.closed)
self.assertFalse(output.exists())
result = json.loads(stdout.getvalue())
self.assertTrue(result["ok"])
self.assertEqual(result["message_count"], 1)
if __name__ == "__main__":
unittest.main()