Files
helper/tests/test_members.py
2026-10-15 18:27:46 +08:00

321 lines
16 KiB
Python

"""成员绑定、持久记录和插件共用接口验证,不连接微信。"""
import json
import logging
import tempfile
import threading
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import Mock, patch
from plugins.member_identity import Plugin
from wxbot_framework.members import MemberError, MemberStore
from wxbot_framework.sdk import Message, PluginContext, ServiceFacade
from wxbot_framework.runtime import BotApplication
from wxbot_framework.config import ConfigStore
ROOT = Path(__file__).resolve().parent.parent
SCRATCH = ROOT / ".runtime/tests"
ROOM = "12345678@chatroom"
ROBOT, SMALL, JING = "wxid_robot", "wxid_small", "wxid_jing"
class MemberTests(unittest.TestCase):
def setUp(self):
SCRATCH.mkdir(parents=True, exist_ok=True)
self.temporary = tempfile.TemporaryDirectory(dir=SCRATCH)
self.root = Path(self.temporary.name).resolve()
self.assertTrue(self.root.is_relative_to(SCRATCH.resolve()))
self.options = {"database_file": "data.sqlite3", "history_enabled": True,
"groups": {ROOM: [
{"key": "xiaomei", "name": "成员甲", "display_name": "成员甲昵称", "aliases": ["小号"], "wxid": SMALL},
{"key": "jing", "name": "成员乙", "display_name": "成员乙群名", "aliases": ["成员乙别名"], "wxid": None},
{"key": "yuting", "name": "成员丁", "display_name": "DemoMemberC", "wxid": None}]}}
self.config_path = self.root / "members.json"
self.write_options()
self.stores = []
self.store = self.create_store()
def write_options(self):
self.config_path.write_text(json.dumps(self.options, ensure_ascii=False), encoding="utf-8")
def create_store(self, account=ROBOT):
store = MemberStore(self.config_path, account)
self.stores.append(store)
return store
def tearDown(self):
for store in self.stores:
store.close()
self.assertTrue(self.root.is_relative_to(SCRATCH.resolve()))
self.temporary.cleanup()
def message(self, text="中文消息 🌸", sender=JING, sequence=1, **kwargs):
return Message(text, ROOM, sender_id=sender, is_group=True, sequence=sequence, session_id="first", **kwargs)
def test_configuration_check_does_not_create_database(self):
self.assertFalse(self.store.path.exists())
self.store.initialize()
self.assertTrue(self.store.path.exists())
def test_aliases_are_resolved_case_insensitively_and_small_is_bound(self):
self.assertEqual(self.store.resolve("小号").wxid, SMALL)
self.assertEqual(self.store.resolve("成员乙群名", ROOM).name, "成员乙")
self.assertEqual(self.store.resolve("demomemberc", ROOM).name, "成员丁")
self.assertIsNone(self.store.resolve("成员乙").wxid)
def test_unknown_names_are_not_guessed(self):
with self.assertRaises(MemberError):
self.store.resolve("未登记的昵称")
with self.assertRaises(MemberError):
self.store.bind("未登记的昵称", ROOM, JING)
def test_binding_survives_restart_and_null_profile_does_not_clear_it(self):
self.store.bind("成员乙群名", ROOM, JING)
self.store.close()
reopened = self.create_store()
self.assertEqual(reopened.resolve("成员乙", ROOM).wxid, JING)
def test_conflicting_binding_does_not_replace_identity(self):
self.store.bind("成员乙", ROOM, JING)
self.assertEqual(self.store.bind("成员乙", ROOM, JING).wxid, JING)
with self.assertRaises(MemberError):
self.store.bind("成员乙", ROOM, "wxid_other")
with self.assertRaises(MemberError):
self.store.bind("成员丁", ROOM, JING)
self.assertEqual(self.store.resolve("成员乙", ROOM).wxid, JING)
def test_history_before_binding_becomes_queryable_by_alias(self):
self.store.record(self.message())
with self.assertRaises(MemberError):
self.store.history("成员乙")
self.store.bind("成员乙", ROOM, JING)
rows = self.store.history("成员乙别名")
self.assertEqual([row["text"] for row in rows], ["中文消息 🌸"])
self.assertEqual(rows[0]["sender_id"], JING)
self.assertEqual(datetime.fromisoformat(rows[0]["received_at"]).utcoffset(), timedelta(0))
def test_history_is_isolated_by_robot_account_and_author(self):
self.store.bind("成员乙", ROOM, JING)
self.store.record(self.message("成员乙的话"))
self.store.record(self.message("成员甲的话", sender=SMALL, sequence=2))
second = self.create_store("wxid_second_robot")
second.bind("成员乙", ROOM, JING)
self.assertEqual(second.history("成员乙"), [])
self.assertEqual([row["text"] for row in self.store.history("成员乙")], ["成员乙的话"])
def test_private_other_group_and_wrong_account_are_not_archived(self):
self.assertFalse(self.store.record(Message("私聊", JING, sender_id=JING, session_id="first")))
self.assertFalse(self.store.record(Message("另一群", "99999@chatroom", sender_id=JING, is_group=True, session_id="first")))
self.assertFalse(self.store.record(self.message(data={"self_wxid": "wxid_other_robot"})))
def test_sequence_deduplication_and_new_session(self):
self.assertTrue(self.store.record(self.message()))
self.assertFalse(self.store.record(self.message()))
self.assertTrue(self.store.record(Message("重连消息", ROOM, JING, is_group=True, sequence=1, session_id="second")))
self.store.bind("成员乙", ROOM, JING)
self.assertEqual(len(self.store.history("成员乙")), 2)
def test_limit_and_time_filter_return_chronological_records(self):
self.store.bind("成员乙", ROOM, JING)
self.store.record(self.message("第一句", sequence=1))
since = datetime.now(timezone.utc)
self.store.record(self.message("第二句", sequence=2))
self.assertEqual([r["text"] for r in self.store.history("成员乙", limit=1)], ["第二句"])
self.assertEqual([r["text"] for r in self.store.history("成员乙", since=since)], ["第二句"])
self.assertEqual([r["text"] for r in self.store.history("成员乙")], ["第一句", "第二句"])
for limit in (True, 0, 1001):
with self.assertRaises(MemberError):
self.store.history("成员乙", limit=limit)
with self.assertRaises(MemberError):
self.store.history("成员乙", since=datetime.now())
def test_disabled_history_keeps_member_directory(self):
self.options["history_enabled"] = False
self.write_options()
disabled = self.create_store()
self.assertEqual(disabled.resolve("成员甲").wxid, SMALL)
self.assertFalse(disabled.record(self.message()))
def test_same_alias_in_two_groups_requires_group(self):
second_room = "99999@chatroom"
self.options["groups"][second_room] = self.options["groups"][ROOM]
self.write_options()
multi = self.create_store()
with self.assertRaises(MemberError):
multi.resolve("成员乙")
self.assertEqual(multi.resolve("成员乙", second_room).room, second_room)
def test_configuration_rejects_duplicate_casefolded_aliases(self):
self.options["groups"][ROOM][1]["aliases"] = ["demomemberc"]
self.write_options()
with self.assertRaises(MemberError):
self.create_store()
def test_recording_and_lookup_from_different_threads(self):
self.store.bind("成员乙", ROOM, JING)
errors = []
def write(index):
try:
self.store.record(self.message(str(index), sequence=index))
self.store.resolve("成员乙")
except Exception as error:
errors.append(error)
threads = [threading.Thread(target=write, args=(index,)) for index in range(1, 13)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(3)
self.assertEqual(errors, [])
self.assertEqual(len(self.store.history("成员乙")), 12)
def test_identity_plugin_binds_actual_message_author(self):
ctx = Mock(members=self.store, logger=logging.getLogger("MembersTest"))
plugin = Plugin()
plugin.on_load(ctx)
plugin.on_message(self.message("/绑定 成员乙"), ctx)
self.assertEqual(self.store.resolve("成员乙").wxid, JING)
ctx.reply.assert_called_once_with(unittest.mock.ANY, "已绑定:成员乙。")
plugin.on_message(self.message("/绑定 成员丁"), ctx)
self.assertIsNone(self.store.resolve("成员丁").wxid)
def test_context_auto_mention_uses_group_name_instead_of_personal_alias(self):
self.store.bind("成员乙", ROOM, JING)
backend = Mock(session_id="first")
service = ServiceFacade(text_delay={"enabled": False})
service.bind(backend)
try:
ctx = PluginContext("example", {}, service, [], member_store=self.store)
self.assertEqual(ctx.members.resolve("成员乙").wxid, JING)
ctx.reply_at(self.message(), "回复")
backend.send_at_text.assert_called_once_with(ROOM, "@成员乙群名\u2005\n回复", (JING,))
finally:
service.close()
def test_runtime_archives_before_plugin_dispatch(self):
(self.root / "plugins").mkdir()
path = self.root / "bot_config.json"
path.write_text(json.dumps({"backend": "native_text", "wechat_account": ROBOT,
"members_config_file": self.config_path.name}), encoding="utf-8")
app = BotApplication(self.root, ConfigStore(path))
try:
app.members.bind("成员乙", ROOM, JING)
def verify(message):
self.assertEqual(app.members.history("成员乙")[0]["text"], message.text)
with patch.object(app.plugins, "dispatch", side_effect=verify):
app._emit(self.message())
finally:
app.stop()
def test_free_registration_accepts_new_name_and_links_existing_history(self):
self.store.record(self.message("绑定前说过的话"))
member = self.store.register("测试成员", ROOM, JING)
self.assertEqual(member.wxid, JING)
self.assertEqual(self.store.history("测试成员")[0]["text"], "绑定前说过的话")
def test_same_account_can_rename_without_losing_aliases_or_history(self):
self.store.register("测试成员", ROOM, JING)
self.store.record(self.message("原有记录"))
self.store.register("成员丙", ROOM, JING)
self.assertEqual(self.store.resolve("测试成员").name, "成员丙")
self.assertEqual(self.store.resolve("成员丙").wxid, JING)
self.assertEqual(self.store.resolve("成员丙").display_name, "成员丙")
self.assertEqual(self.store.history("成员丙")[0]["text"], "原有记录")
self.assertEqual(len([m for m in self.store.list_members() if m.wxid == JING]), 1)
def test_another_account_cannot_take_existing_nickname(self):
self.store.register("测试成员", ROOM, JING)
with self.assertRaises(MemberError):
self.store.register("测试成员", ROOM, SMALL)
self.assertEqual(self.store.resolve("测试成员").wxid, JING)
def test_registered_name_survives_config_seeding_on_restart(self):
self.store.register("成员乙", ROOM, JING)
self.store.register("测试成员", ROOM, JING)
self.store.close()
reopened = self.create_store()
self.assertEqual(reopened.resolve("成员乙").name, "测试成员")
self.assertEqual(reopened.resolve("测试成员").wxid, JING)
def test_free_registration_still_limits_groups_and_name_shape(self):
with self.assertRaises(MemberError):
self.store.register("测试成员", "99999@chatroom", JING)
for name in ("", "x" * 65, "喵\n喵", "喵\0喵"):
with self.subTest(name=name), self.assertRaises(MemberError):
self.store.register(name, ROOM, JING)
def test_group_binding_overrides_legacy_display_and_survives_restart(self):
self.store.register("成员甲", ROOM, SMALL)
self.assertEqual(self.store.display_name(SMALL, ROOM), "成员甲")
self.store.close()
self.store = self.create_store()
self.assertEqual(self.store.display_name(SMALL, ROOM), "成员甲")
self.assertTrue(self.store.resolve("成员甲", ROOM).self_named)
def test_wechat_default_and_bindings_are_separate_in_each_group(self):
other_room = "87654321@chatroom"
self.options["groups"][other_room] = []
self.write_options()
store = self.create_store()
store.register("成员甲", ROOM, SMALL)
store.remember_wechat_name(SMALL, "DemoUser")
message = Message("新群的原话", other_room, SMALL, is_group=True, session_id="new", sequence=1)
store.record(message)
self.assertEqual(store.display_name(SMALL, other_room), "DemoUser")
self.assertEqual(store.list_members(other_room), [])
self.assertEqual(store.today_snapshot("DemoUser", other_room).member.wxid, SMALL)
backend = Mock(session_id="new")
service = ServiceFacade(text_delay={"enabled": False})
service.bind(backend)
try:
ctx = PluginContext("test", {}, service, [], member_store=store)
ctx.reply_at(message, "默认回复")
self.assertEqual(backend.send_at_text.call_args.args[1], "@DemoUser\u2005\n默认回复")
store.register("新群名字", other_room, SMALL)
ctx.reply_at(message, "已绑定", display_name="旧的硬编码名字")
self.assertEqual(backend.send_at_text.call_args.args[1], "@新群名字\u2005\n已绑定")
self.assertEqual(store.display_name(SMALL, ROOM), "成员甲")
self.assertEqual(store.today_snapshot("新群名字", other_room).messages[0]["text"], "新群的原话")
finally:
service.close()
def test_native_wechat_name_updates_default_without_overwriting_group_binding(self):
other_room = "87654321@chatroom"
self.options["groups"][other_room] = []
self.write_options()
store = self.create_store()
store.register("成员甲", ROOM, SMALL)
for sequence, name in ((1, "DemoUser"), (2, "新的微信名")):
msg = Message("原文", other_room, SMALL, is_group=True, session_id="new", sequence=sequence,
data={"sender_wechat_name": name, "self_wxid": ROBOT})
store.record(msg)
self.assertEqual(store.display_name(SMALL, other_room), name)
self.assertEqual(store.display_name(SMALL, ROOM), "成员甲")
self.assertEqual(store.today_group_snapshot(other_room).messages[-1]["speaker_name"], "新的微信名")
self.assertEqual(store.conversation_snapshot(msg).speaker_name, "新的微信名")
store.clear(other_room)
self.assertEqual(store.today_snapshot("新的微信名", other_room).messages, ())
def test_duplicate_default_wechat_names_do_not_mix_member_identities(self):
self.options["groups"][ROOM] = []
self.write_options()
store = self.create_store()
for index, wxid in enumerate((SMALL, JING), 1):
store.record(self.message("不同成员", sender=wxid, sequence=index,
data={"sender_wechat_name": "同名"}))
with self.assertRaisesRegex(MemberError, "多个成员"):
store.today_snapshot("同名", ROOM)
store.register("指定成员", ROOM, SMALL)
self.assertEqual(store.today_snapshot("指定成员", ROOM).member.wxid, SMALL)
def test_plugin_free_registration_and_usage(self):
ctx = Mock(members=self.store, logger=logging.getLogger("MembersTest"))
plugin = Plugin()
plugin.on_load(ctx)
plugin.on_message(self.message("/绑定 测试成员"), ctx)
self.assertEqual(self.store.resolve("测试成员").wxid, JING)
plugin.on_message(self.message("/绑定"), ctx)
self.assertEqual(ctx.reply.call_args.args[1], "用法:/绑定 昵称")
plugin.on_message(self.message(" /绑定\t成员丙 "), ctx)
self.assertEqual(self.store.resolve("成员丙").wxid, JING)