Files
svfs/tests/test_client_messages.py
T

331 lines
16 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""消息模块纯标准库层的测试:主题方案 / 消息封皮与日志 / 统一总线。
不 import Qt、不连 broker——这一层必须能在最小环境里跑通(分层约定见
src/svfs_client/__init__.py)。
"""
import sys
import unittest
from helpers import VFSTestCase
from svfs_client import bus as bus_mod
from svfs_client import messages as messages_mod
from svfs_client import topics
assert sys.modules.get("helpers") is not None
class TopicTest(unittest.TestCase):
"""主题方案:组织位、转义可逆、通配订阅。"""
ORG = "acme"
USER = "alice"
def test_topics_carry_org_and_user(self):
self.assertEqual(
topics.user_message_topic("svfs", self.ORG, self.USER),
"svfs/acme/user/alice/message")
self.assertEqual(
topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.clock"),
"svfs/acme/user/alice/plugin/com.example.clock")
self.assertEqual(
topics.broadcast_message_topic("svfs", self.ORG),
"svfs/acme/broadcast/message")
self.assertEqual(
topics.broadcast_plugin_topic("svfs", self.ORG, "com.example.clock"),
"svfs/acme/broadcast/plugin/com.example.clock")
def test_defaults_fill_in_prefix_and_org(self):
self.assertEqual(topics.user_message_topic("", "", "alice"),
"svfs/default/user/alice/message")
# 前后多余的斜杠不该造出空层级
self.assertEqual(topics.broadcast_message_topic("/svfs/", "acme"),
"svfs/acme/broadcast/message")
def test_parse_roundtrip(self):
topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.x")
info = topics.parse(topic, "svfs")
self.assertEqual(info["org"], self.ORG)
self.assertEqual(info["scope"], "user")
self.assertEqual(info["user"], self.USER)
self.assertEqual(info["kind"], "plugin")
self.assertEqual(info["plugin"], "com.example.x")
info = topics.parse(topics.broadcast_message_topic("svfs", self.ORG), "svfs")
self.assertEqual((info["scope"], info["kind"], info["user"], info["plugin"]),
("broadcast", "message", "", ""))
def test_parse_rejects_other_schemes(self):
for topic in ("", "svfs", "svfs/acme", "svfs/acme/user",
"other/acme/user/alice/message", # 前缀不对
"svfs/acme/user/alice/unknown", # 类别不认识
"svfs/acme/user/alice/plugin", # 少了插件 id
"svfs/acme/broadcast", # 少了类别
"svfs/acme/broadcast/plugin/"): # 空插件 id
self.assertIsNone(topics.parse(topic, "svfs"), topic)
def test_unsafe_segments_are_escaped_and_invertible(self):
# 空格、中文、斜杠都不能破坏层级;解析回来必须还是原值
for user in ("a b", "张三", "a/b", "a+b", "a#b", "a@corp.example"):
topic = topics.user_message_topic("svfs", self.ORG, user)
self.assertEqual(len(topic.split("/")), 5, topic)
self.assertEqual(topics.parse(topic, "svfs")["user"], user)
def test_escaping_never_collides(self):
# 转义必须是可逆的:不能把两个不同用户映射到同一个主题
self.assertNotEqual(topics.topic_segment("a b"),
topics.topic_segment("a_b"))
self.assertNotEqual(topics.topic_segment("a/b"),
topics.topic_segment("ab"))
self.assertEqual(topics.topic_segment("safe.name-1@x"), "safe.name-1@x")
def test_empty_segment_is_rejected(self):
with self.assertRaises(ValueError):
topics.topic_segment("")
with self.assertRaises(ValueError):
topics.user_message_topic("svfs", self.ORG, "")
def test_subscriptions_are_least_privilege(self):
subs = topics.subscriptions("svfs", self.ORG, self.USER)
self.assertEqual(subs, ["svfs/acme/user/alice/#", "svfs/acme/broadcast/#"])
# 不订阅整个组织的 #(多租户下这正是 ACL 要挡的范围)
self.assertNotIn("svfs/acme/#", subs)
class MessageLogTest(VFSTestCase):
"""消息封皮解析与消息中心列表。"""
def test_parse_json_envelope(self):
message = messages_mod.parse_message(
'{"id": "m1", "title": "备份完成", "body": "3 个包",'
' "level": "warn", "time": "2026-09-16T10:00:00", "source": "backup"}')
self.assertEqual(message["id"], "m1")
self.assertEqual(message["title"], "备份完成")
self.assertEqual(message["body"], "3 个包")
self.assertEqual(message["level"], "warn")
self.assertEqual(message["time"], "2026-09-16T10:00:00")
self.assertEqual(message["source"], "backup")
self.assertTrue(message["unread"])
def test_parse_plain_and_scalar_payloads(self):
self.assertEqual(messages_mod.parse_message(" 开机完成 ")["body"], "开机完成")
self.assertEqual(messages_mod.parse_message('"一句话"')["body"], "一句话")
self.assertEqual(messages_mod.parse_message("[1,2]")["body"], "[1, 2]")
self.assertEqual(messages_mod.parse_message(b"\xe4\xb8\xad\xe6\x96\x87")["body"],
"中文")
def test_parse_rejects_empty_and_contentless(self):
for payload in (None, "", " ", "{}", '{"title": ""}', b""):
self.assertIsNone(messages_mod.parse_message(payload), repr(payload))
def test_parse_normalizes_level_and_time(self):
message = messages_mod.parse_message('{"body": "x", "level": "FATAL"}',
received_time="2026-09-16T12:00:00")
self.assertEqual(message["level"], "info") # 新级别也当 info,消息不能丢
self.assertEqual(message["time"], "2026-09-16T12:00:00") # 缺失时用收到时间
self.assertTrue(messages_mod.parse_message('{"body": "x"}')["time"])
def test_parse_caps_length(self):
message = messages_mod.parse_message(
'{"title": "%s", "body": "%s"}' % ("t" * 500, "b" * 9000))
self.assertLessEqual(len(message["title"]), 200)
self.assertLessEqual(len(message["body"]), messages_mod.MAX_TEXT)
def test_log_dedupes_by_id(self):
log = messages_mod.MessageLog()
self.assertIsNotNone(log.add(messages_mod.parse_message('{"id": "m1", "body": "a"}')))
# 同 id 重投(QoS 1 重发 / 补齐与推送重叠)→ 丢掉
self.assertIsNone(log.add(messages_mod.parse_message('{"id": "m1", "body": "a"}')))
self.assertEqual(len(log), 1)
def test_log_dedupes_id_less_messages_by_content_and_second(self):
"""服务端没给 id 时按"时间(秒)+ 标题 + 正文"去重——这正是没有 id 的
消息同时经推送与 HTTP 补齐两条路到达的场景,显示一条才对。"""
log = messages_mod.MessageLog()
payload = '{"body": "同一条", "time": "2026-09-16T10:00:00"}'
self.assertIsNotNone(log.add(messages_mod.parse_message(payload)))
self.assertIsNone(log.add(messages_mod.parse_message(payload)))
self.assertEqual(len(log), 1)
# 时间不同(真·第二条)或内容不同都算新消息
self.assertIsNotNone(log.add(messages_mod.parse_message(
'{"body": "同一条", "time": "2026-09-16T10:00:01"}')))
self.assertIsNotNone(log.add(messages_mod.parse_message(
'{"body": "另一条", "time": "2026-09-16T10:00:01"}')))
self.assertEqual(len(log), 3)
def test_log_unread_and_mark_read(self):
log = messages_mod.MessageLog()
for index in range(3):
log.add(messages_mod.parse_message(f'{{"id": "m{index}", "body": "x"}}'))
self.assertEqual(log.unread(), 3)
self.assertEqual(log.mark_all_read(), 3)
self.assertEqual(log.unread(), 0)
self.assertEqual(log.mark_all_read(), 0)
log.add(messages_mod.parse_message('{"id": "m9", "body": "y"}'))
self.assertTrue(log.mark_read("m9"))
self.assertFalse(log.mark_read("m9")) # 已经读过
self.assertFalse(log.mark_read("nope"))
def test_log_caps_and_forgets_dropped_keys(self):
log = messages_mod.MessageLog(limit=3)
for index in range(5):
log.add(messages_mod.parse_message(f'{{"id": "m{index}", "body": "x"}}'))
self.assertEqual([m["id"] for m in log.items()], ["m2", "m3", "m4"])
# 被挤掉的 id 不再占去重表:同样的 id 再来是"新消息"(而不是永久沉默)
self.assertIsNotNone(
log.add(messages_mod.parse_message('{"id": "m0", "body": "x"}')))
def test_log_clear(self):
log = messages_mod.MessageLog()
log.add(messages_mod.parse_message('{"id": "m1", "body": "x"}'))
log.clear()
self.assertEqual(len(log), 0)
self.assertEqual(log.unread(), 0)
self.assertIsNotNone(
log.add(messages_mod.parse_message('{"id": "m1", "body": "x"}')))
class MessageBusTest(VFSTestCase):
"""总线的分类、归属校验与分发。"""
ORG = "acme"
USER = "alice"
def setUp(self):
super().setUp()
self.bus = bus_mod.MessageBus(org=self.ORG, user=self.USER)
def _deliver(self, topic, payload, received_time=""):
return self.bus.deliver(topic, payload, received_time=received_time)
# ---- 分类 -------------------------------------------------------
def test_host_message_goes_to_message_log(self):
topic = topics.user_message_topic("svfs", self.ORG, self.USER)
result = self._deliver(topic, '{"id": "m1", "title": "备份完成"}')
self.assertEqual(result["action"], "host")
self.assertEqual(len(self.bus.log), 1)
self.assertEqual(self.bus.log.items()[0]["title"], "备份完成")
self.assertEqual(self.bus.log.items()[0]["scope"], "user")
def test_plugin_message_never_reaches_message_log(self):
topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash")
result = self._deliver(topic, '{"cpu": 0.42}')
self.assertEqual(result["action"], "plugin")
self.assertEqual(result["plugin"], "com.example.dash")
self.assertEqual(len(self.bus.log), 0, "插件消息不能进消息中心")
# 插件取走原文(宿主不解析插件协议)
drained = self.bus.drain("com.example.dash")
self.assertEqual(len(drained), 1)
self.assertEqual(drained[0]["payload"], '{"cpu": 0.42}')
self.assertEqual(self.bus.drain("com.example.dash"), [])
def test_broadcast_plugin_message(self):
topic = topics.broadcast_plugin_topic("svfs", self.ORG, "com.example.dash")
result = self._deliver(topic, "tick")
self.assertEqual(result["action"], "plugin")
self.assertEqual(result["scope"], "broadcast")
self.assertEqual(self.bus.pending("com.example.dash"), 1)
def test_broadcast_host_message_is_marked(self):
topic = topics.broadcast_message_topic("svfs", self.ORG)
self._deliver(topic, '{"body": "全员通知"}')
self.assertEqual(self.bus.log.items()[0]["scope"], "broadcast")
def test_unknown_topic_and_empty_payload(self):
self.assertEqual(self._deliver("svfs/acme/user/alice/nope", "x")["action"],
"unknown")
topic = topics.user_message_topic("svfs", self.ORG, self.USER)
self.assertEqual(self._deliver(topic, " ")["action"], "empty")
self.assertEqual(len(self.bus.log), 0)
# ---- 归属校验 ---------------------------------------------------
def test_other_org_or_user_is_rejected(self):
other_org = topics.user_message_topic("svfs", "other", self.USER)
self.assertEqual(self._deliver(other_org, '{"body": "x"}')["action"],
"forbidden")
other_user = topics.user_message_topic("svfs", self.ORG, "bob")
self.assertEqual(self._deliver(other_user, '{"body": "x"}')["action"],
"forbidden")
forbidden_plugin = topics.user_plugin_topic("svfs", self.ORG, "bob",
"com.example.dash")
self.assertEqual(self._deliver(forbidden_plugin, "x")["action"], "forbidden")
self.assertEqual(len(self.bus.log), 0)
self.assertEqual(self.bus.pending("com.example.dash"), 0)
self.assertEqual(self.bus.stats()["forbidden"], 3)
def test_identify_switches_tenant_and_clears_buffers(self):
self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"),
"x")
self.assertEqual(self.bus.pending("com.a"), 1)
self.bus.identify(org="other", user="bob")
self.assertEqual(self.bus.pending("com.a"), 0, "换身份后不该留着旧队列")
self.assertEqual(
self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER),
'{"body": "x"}')["action"], "forbidden")
def test_bus_rejects_everything_before_identify(self):
"""没有身份(未登录/已退出登录)时一条都不收。
消息模块只在登录后工作;退出登录后残留的推送不该落进消息中心。
"""
bus = bus_mod.MessageBus()
result = bus.deliver(topics.broadcast_message_topic("svfs", "acme"),
'{"body": "x"}')
self.assertEqual(result["action"], "forbidden")
self.assertEqual(len(bus.log), 0)
bus.identify(org="acme")
self.assertEqual(
bus.deliver(topics.broadcast_message_topic("svfs", "acme"),
'{"body": "x"}')["action"], "host")
# ---- 去重与兜底 -------------------------------------------------
def test_plugin_duplicates_are_dropped(self):
topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash")
self.assertEqual(self._deliver(topic, "same")["action"], "plugin")
self.assertEqual(self._deliver(topic, "same")["action"], "duplicate")
self.assertEqual(self.bus.pending("com.example.dash"), 1)
# 内容不同就是新消息
self.assertEqual(self._deliver(topic, "other")["action"], "plugin")
def test_plugin_buffer_drops_oldest_and_counts(self):
bus = bus_mod.MessageBus(org=self.ORG, user=self.USER, plugin_buffer=3)
topic = topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.example.dash")
for index in range(5):
bus.deliver(topic, f"payload-{index}")
drained = bus.drain("com.example.dash")
self.assertEqual([item["payload"] for item in drained],
["payload-2", "payload-3", "payload-4"])
self.assertEqual(bus.stats()["dropped"], 2)
def test_reset_clears_everything(self):
self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER),
'{"id": "m1", "body": "x"}')
self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"), "x")
self.bus.reset()
self.assertEqual(len(self.bus.log), 0)
self.assertEqual(self.bus.pending("com.a"), 0)
self.assertEqual(self.bus.stats(), {})
def test_stats_track_each_channel(self):
self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER),
'{"id": "m1", "body": "x"}')
self._deliver(topics.user_message_topic("svfs", self.ORG, self.USER),
'{"id": "m1", "body": "x"}') # 重投
self._deliver(topics.user_plugin_topic("svfs", self.ORG, self.USER, "com.a"),
"x")
stats = self.bus.stats()
self.assertEqual(stats["received"], 3)
self.assertEqual(stats["host"], 1)
self.assertEqual(stats["plugin"], 1)
self.assertEqual(stats["duplicate"], 1)
self.assertEqual(self.bus.buffered_plugins(), ["com.a"])
if __name__ == "__main__":
unittest.main()