feat: 消息模块(MQTT 推送 + 统一总线),并补退出登录/顺序编排/设置分页签

一、消息模块:服务端主动推送 + 插件定向分发
* topics.py:主题方案 <prefix>/<org>/user/<user>/message、.../user/<user>/plugin/<id>、
  .../broadcast/{message,plugin/<id>}。org 是租户位(多租户上线不用改格式,broker
  按 <prefix>/<org>/# 下发 ACL 即可隔离);用户名/插件 id 经**可逆**百分号编码占
  一个层级,客户端与示例服务端共用同一套拼主题函数;
* bus.py:统一消息总线——按**主题**分类(主机消息进消息中心、插件消息不进,仪表盘
  数据那类业务载荷永远不会混进给人看的列表)、归属校验(org/user 不符即丢弃,broker
  ACL 配错也挡得住)、按 id 去重(QoS 1 重投与"推送/补齐重叠"都不会重复显示)、
  插件消息按插件缓存 50 条,等页面打开后一次性补投;
* mqtt.py + mqtt_service.py:paho 适配(可选依赖 [mqtt])+ Qt 适配(paho 的网络
  线程经排队信号把消息交给主线程的总线);重连交给 paho(指数退避),重连成功
  自动重新订阅;没装 paho / 没配 broker 都只是"没有实时推送",HTTP 补齐照常;
* worker.py:登录后**先**补齐(GET /api/messages?since=)再连 MQTT,两条路进同一
  总线;在途结果带身份快照,退出登录/换账号后丢弃;
* 界面:侧栏 ✉ 未读徽标 + 消息中心(级别/标题/时间/来源/广播标记、立即同步、
  全部已读、清空、通道状态灯);warn/error 顺手发系统托盘通知;
* 插件侧:新权限 messages(PLUGIN_API_VERSION→5),页面里 svfs.messages.subscribe();
  宿主经 window.__svfsPush 投递,早到的先排队、订阅时补发(桥调试台已接上演示);
* examples/plugin_server.py:/api/messages、/api/push(kind=message|plugin)、
  /api/broadcast;配了 SVFS_MQTT_HOST 就真发布到 broker,并把 broker 地址随登录
  响应下发(/api/login 与 /api/me 都带),客户端不用手填。

二、退出登录 = 关掉主窗口 + 回到登录界面
* 强制登录下"未登录的主窗口"本就不该存在:重新登录成功用新会话开回主窗口,关掉
  登录窗即退出客户端;退出时断开推送、清掉该账号收到的消息与总线身份(下一个人
  不该看到上一个人的消息),并收掉已打开的插件页面;
* 用户中心的"退出登录"先关掉自己再进登录门,避免模态窗口叠模态。

三、插件顺序编排
* installer.arrange():编排过的在前、未编排的按名称;管理页卡片加 ↑/↓,侧栏/图标栏/
  管理页三者一致;存 settings 的 plugin_order,只影响显示(不动包与注册表);
* "启动时打开"的默认规则改为"显示顺序里第一个启用的插件"(原来是按 id 排);
* 新增「恢复默认顺序」(编排过才出现)。

四、修复
* 收缩侧栏后页面左上角露出一块纯白方块:页面栈对 Qt 是透明的(主题只给
  QMainWindow/QDialog 设了背景),侧栏让出的那条在重绘时序里露白。给 QStackedWidget
  不透明底色,并加像素级回归用例(撤掉修复即 FAIL,实测 1237 个白像素);
* build_opener() 每次都要隐式枚举 Windows 证书库:全量测试跑几百次后以断点异常
  崩在证书枚举里(faulthandler 定位在 _load_windows_store_certs)。改成进程内只建
  一次 SSL context、启动时在主线程预建;
* 在途的消息补齐结果会喂给新会话(退出登录时用户名已空 → 拼主题抛异常,被 Qt 回调
  静默吞掉):带身份快照,不匹配即丢弃。

五、设置界面按类型分页签(插件 / 服务器 / 存储 / 网络 / 诊断)
* 每页内容在滚动区里(窗口拉小或以后再加项是滚动条,而不是把控件压扁);
* 新增「消息推送(MQTT)」一节:broker(host / host:port / mqtts://host:8883)、组织、
  端口、TLS、主题前缀、broker 账号,保存即重连;状态行显示**生效**配置与来源
  (手配 / 服务端下发)。broker 密码不进界面:默认用登录 token(已加密存储),需要
  单独的密码时由服务端下发——与"凭据不明文落盘"保持一致。

测试与验证
* 新增 tests/test_client_messages.py(主题/封皮/总线,纯层)、tests/test_client_mqtt.py
  (装配层 + 自建最小 broker 的端到端,不依赖 mosquitto)、test_plugin_server.py 的
  消息接口与"/api/push → broker → 客户端总线"闭环;WebEngine 冒烟里加了页面级
  messages 探针;
* **测试入口改为 tests/run_tests.py**:QtWebEngine 在 offscreen 下的解释器退出路径会
  崩(测试全绿但退出码 139,CI 只看退出码就会判失败),runner 按 unittest 的结果
  os._exit 绕开那段清理;测试真崩在中途时退出码照样非 0(用故意失败的用例验证过)。
  README 与 CI 都切到这个入口;
* 全量 720 条通过(6 skip),exit 0。
This commit is contained in:
2026-09-17 01:06:02 +08:00
parent ae9fc8dbe4
commit e0d5bad7b2
29 changed files with 4105 additions and 105 deletions
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""测试入口:跑全部测试,然后按 unittest 的判定结果退出。
为什么需要这一层(而不是直接 ``python -m unittest discover -s tests``):
**QtWebEngine 在 offscreen 下的解释器退出路径会崩**(Windows 实测:测试全绿、
进程却以段错误结束,退出码 139)。CI 只看退出码,于是绿色测试被判成失败;
而且崩在哪一步与用例数量有关,非常难缠。测试的成败本来就该由 unittest 的
``result`` 决定,所以拿到结果后立刻 ``os._exit``,绕开那段我们控制不了的
C++/解释器清理路径。
注意这**不掩盖真实崩溃**:测试跑到一半崩(进程真的挂了)时,unittest 根本
拿不到结果,退出码照样非 0。
用法::
python tests/run_tests.py # 全部
python tests/run_tests.py -p "test_client_*" # 只跑一部分(同 discover 的 -p)
python tests/run_tests.py -v # 逐条打印
"""
import argparse
import os
import sys
import unittest
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("-p", "--pattern", default="test_*.py",
help="测试文件匹配(同 unittest discover 的 -p)")
parser.add_argument("-v", "--verbose", action="store_true", help="逐条打印")
parser.add_argument("-f", "--failfast", action="store_true",
help="第一个失败就停")
args = parser.parse_args(argv)
# 从测试目录里发现:helpers.py 与测试模块都按"平铺导入"写(见 helpers)
sys.path.insert(0, TESTS_DIR)
os.chdir(TESTS_DIR)
suite = unittest.TestLoader().discover(TESTS_DIR, pattern=args.pattern)
runner = unittest.TextTestRunner(verbosity=2 if args.verbose else 1,
failfast=args.failfast)
result = runner.run(suite)
passed = result.wasSuccessful()
summary = ("全部通过" if passed else
f"失败 {len(result.failures)} / 错误 {len(result.errors)}")
sys.stdout.write(f"{summary}\n")
sys.stdout.flush()
sys.stderr.flush()
# 立刻退出:不要让解释器去跑那段会崩的 Qt/Chromium 清理
os._exit(0 if passed else 1)
if __name__ == "__main__":
main()
+73 -4
View File
@@ -257,10 +257,13 @@ class SettingsTest(VFSTestCase):
self.assertEqual(
data,
{"base_url": "", "auth_token": "", "auth_user": "",
"startup_plugin": "", "cache_dir": "",
"startup_plugin": "", "plugin_order": [], "cache_dir": "",
"net_timeout": settings.NET_TIMEOUT_DEFAULT,
"net_retries": settings.NET_RETRIES_DEFAULT,
"proxy": "", "ca_bundle": "", "log_to_file": True},
"proxy": "", "ca_bundle": "", "log_to_file": True,
"org": "", "mqtt_host": "",
"mqtt_port": settings.MQTT_PORT_DEFAULT, "mqtt_tls": False,
"mqtt_prefix": "svfs", "mqtt_username": "", "mqtt_password": ""},
)
data["base_url"] = "http://10.0.0.5:9000"
settings.save(self.tmp, data)
@@ -271,10 +274,13 @@ class SettingsTest(VFSTestCase):
self.assertEqual(
settings.load(self.tmp),
{"base_url": "", "auth_token": "", "auth_user": "",
"startup_plugin": "", "cache_dir": "",
"startup_plugin": "", "plugin_order": [], "cache_dir": "",
"net_timeout": settings.NET_TIMEOUT_DEFAULT,
"net_retries": settings.NET_RETRIES_DEFAULT,
"proxy": "", "ca_bundle": "", "log_to_file": True},
"proxy": "", "ca_bundle": "", "log_to_file": True,
"org": "", "mqtt_host": "",
"mqtt_port": settings.MQTT_PORT_DEFAULT, "mqtt_tls": False,
"mqtt_prefix": "svfs", "mqtt_username": "", "mqtt_password": ""},
)
def test_resolve_cache_dir(self):
@@ -587,6 +593,69 @@ class VersionCompareTest(VFSTestCase):
self.assertEqual(result["current"], "0.1.0")
class PluginOrderTest(VFSTestCase):
"""插件顺序编排(纯函数 + 设置持久化)。"""
@staticmethod
def _entry(plugin_id, name):
return {"id": plugin_id, "name": name}
def test_arrange_without_order_keeps_name_order(self):
entries = [self._entry("com.b", "Bravo"), self._entry("com.a", "Alpha")]
arranged = installer.arrange(entries, [])
self.assertEqual([e["id"] for e in arranged], ["com.a", "com.b"])
# 空 order 与 None 等价("从没编排过"这两种写法都可能出现)
self.assertEqual(installer.arrange(entries, None), arranged)
# 不改动传入的列表(原地排序会污染调用方持有的 payload)
self.assertEqual([e["id"] for e in entries], ["com.b", "com.a"])
def test_arrange_puts_ordered_ids_first(self):
entries = [self._entry("com.a", "Alpha"), self._entry("com.b", "Bravo"),
self._entry("com.c", "Charlie")]
arranged = installer.arrange(entries, ["com.c", "com.a"])
self.assertEqual([e["id"] for e in arranged],
["com.c", "com.a", "com.b"]) # 未编排的按名称跟后面
def test_arrange_ignores_uninstalled_ids(self):
entries = [self._entry("com.a", "Alpha"), self._entry("com.b", "Bravo")]
# com.gone 已卸载:当没看见,剩下两个仍按给定顺序排
arranged = installer.arrange(entries, ["com.gone", "com.b"])
self.assertEqual([e["id"] for e in arranged], ["com.b", "com.a"])
def test_arrange_is_stable_for_new_plugins(self):
"""新装的插件(不在 order 里)落在"未编排"那一档,不干扰已编排的。"""
entries = [self._entry("com.a", "Alpha"), self._entry("com.new", "Zulu"),
self._entry("com.b", "Bravo")]
arranged = installer.arrange(entries, ["com.b", "com.a"])
self.assertEqual([e["id"] for e in arranged],
["com.b", "com.a", "com.new"])
def test_plugin_order_roundtrip_and_cleanup(self):
base = paths.ensure_layout(self.path("data"))
self.assertEqual(settings.plugin_order(base), [])
# 去重 + 保序 + 丢掉空值
self.assertEqual(
settings.set_plugin_order(base, ["com.b", "com.a", "com.b", "", None]),
["com.b", "com.a"],
)
self.assertEqual(settings.plugin_order(base), ["com.b", "com.a"])
# 复位
settings.set_plugin_order(base, [])
self.assertEqual(settings.plugin_order(base), [])
def test_plugin_order_survives_bad_values(self):
base = paths.ensure_layout(self.path("data"))
data = settings.load(base)
data["plugin_order"] = "com.a" # 手改坏成字符串
settings.save(base, data)
self.assertEqual(settings.plugin_order(base), [])
# 返回的是副本:改它不影响模块级默认值(否则后面每次读取都被污染)
first = settings.load(base)["plugin_order"]
first.append("com.polluted")
self.assertEqual(settings.load(base)["plugin_order"], [])
self.assertEqual(settings.DEFAULTS["plugin_order"], [])
class DiagnosticsTest(VFSTestCase):
"""诊断日志:环形缓冲、报告、自身绝不抛异常。"""
+330
View File
@@ -0,0 +1,330 @@
#!/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()
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env python3
"""MQTT 推送通道的测试。
分两层:
* **装配层**:注入假客户端,确定性地验证"连接参数、订阅哪两条主题、状态机、
重连语义、缺 paho 时的降级"——不依赖网络与 broker;
* **端到端**:自建一个最小 MQTT broker(3.1.1、QoS 0,只实现 CONNECT /
SUBSCRIBE / PUBLISH / PINGREQ / DISCONNECT),用**真 paho 客户端**连上去,
让 broker 主动推消息,验证消息真的落进总线:主机消息进消息中心、插件消息
只进插件队列。没装 paho 时这一层自动跳过。
"""
import json
import socket
import struct
import sys
import threading
import time
import unittest
from helpers import VFSTestCase
from svfs_client import bus as bus_mod
from svfs_client import mqtt as mqtt_mod
from svfs_client import settings, topics
assert sys.modules.get("helpers") is not None
try:
import paho.mqtt.client as _paho # noqa: F401 - 端到端测试需要
HAVE_PAHO = True
except Exception:
HAVE_PAHO = False
def config(**overrides):
base = {"enabled": True, "host": "127.0.0.1", "port": 1883, "tls": False,
"prefix": "svfs", "org": "acme", "username": "alice",
"password": "tok", "keepalive": 30}
base.update(overrides)
return base
def wait_until(predicate, timeout=10.0, interval=0.02):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(interval)
return False
class _FakeClient:
"""paho 客户端的替身:记录调用,手动触发回调。"""
def __init__(self):
self.calls = []
self.subscriptions = []
self.on_connect = None
self.on_disconnect = None
self.on_message = None
self.username_pw = None
self.tls = None
self.reconnect_delay = None
# paho 的接口
def connect_async(self, host, port, keepalive):
self.calls.append(("connect_async", host, port, keepalive))
def loop_start(self):
self.calls.append(("loop_start",))
def loop_stop(self):
self.calls.append(("loop_stop",))
def disconnect(self):
self.calls.append(("disconnect",))
def subscribe(self, topic):
self.subscriptions.append(topic)
self.calls.append(("subscribe", topic))
def username_pw_set(self, username, password=None):
self.username_pw = (username, password)
def tls_set(self, **kwargs):
self.tls = kwargs
def reconnect_delay_set(self, min_delay, max_delay):
self.reconnect_delay = (min_delay, max_delay)
# 测试侧的触发点
def fire_connect(self, code=0):
self.on_connect(self, None, {}, code, None)
def fire_disconnect(self, code=0):
self.on_disconnect(self, None, None, code, None)
def fire_message(self, topic, payload):
self.on_message(self, None, _Message(topic, payload))
class _Message:
def __init__(self, topic, payload):
self.topic = topic
self.payload = payload if isinstance(payload, bytes) else payload.encode()
class TransportWiringTest(VFSTestCase):
"""装配层:参数、订阅、状态机(不需要 broker)。"""
def setUp(self):
super().setUp()
self.states = []
self.messages = []
self.client = _FakeClient()
def transport(self, **overrides):
return mqtt_mod.MqttTransport(
config(**overrides),
on_message=lambda topic, payload: self.messages.append((topic, payload)),
on_state=lambda state, detail: self.states.append((state, detail)),
client=self.client,
)
def test_disabled_config_does_not_connect(self):
transport = mqtt_mod.MqttTransport({"enabled": False},
client=self.client)
self.assertEqual(transport.start(), mqtt_mod.STATE_DISABLED)
self.assertEqual(self.client.calls, [])
def test_missing_paho_degrades_with_hint(self):
original = mqtt_mod._import_paho
mqtt_mod._import_paho = lambda: None
self.addCleanup(setattr, mqtt_mod, "_import_paho", original)
transport = mqtt_mod.MqttTransport(config(), on_state=lambda *a: None)
self.assertEqual(transport.start(), mqtt_mod.STATE_MISSING)
self.assertIn("paho", transport.detail)
def test_start_connects_subscribes_and_reports_state(self):
transport = self.transport()
self.assertEqual(transport.start(), mqtt_mod.STATE_CONNECTING)
self.assertIn(("connect_async", "127.0.0.1", 1883, 30), self.client.calls)
self.assertIn(("loop_start",), self.client.calls)
self.assertEqual(self.client.username_pw, ("alice", "tok"))
self.assertFalse(self.client.tls) # 非 TLS 不调 tls_set
self.assertEqual(self.client.reconnect_delay,
(mqtt_mod.RECONNECT_MIN_DELAY, mqtt_mod.RECONNECT_MAX_DELAY))
self.client.fire_connect(0)
self.assertTrue(transport.is_connected())
# 订阅的是"自己的 + 组织广播"两条,不是整个组织
self.assertEqual(self.client.subscriptions,
["svfs/acme/user/alice/#", "svfs/acme/broadcast/#"])
self.assertEqual(self.states[-1][0], mqtt_mod.STATE_CONNECTED)
def test_reconnect_resubscribes(self):
transport = self.transport()
transport.start()
self.client.fire_connect(0)
self.client.subscriptions.clear()
# 掉线 → paho 自动重连 → 重新订阅(broker 不记得旧会话的订阅)
self.client.fire_disconnect(7)
self.assertEqual(transport.state, mqtt_mod.STATE_CONNECTING)
self.client.fire_connect(0)
self.assertEqual(len(self.client.subscriptions), 2)
self.assertEqual(transport.connected_count, 2)
def test_refused_connection_reports_error(self):
transport = self.transport()
transport.start()
self.client.fire_connect(5) # 5 = 未授权
self.assertEqual(transport.state, mqtt_mod.STATE_ERROR)
self.assertIn("拒绝", transport.detail)
def test_message_callback_hands_payload_to_bus(self):
transport = self.transport()
transport.start()
self.client.fire_connect(0)
self.client.fire_message("svfs/acme/user/alice/message", '{"body": "hi"}')
self.assertEqual(self.messages,
[("svfs/acme/user/alice/message", b'{"body": "hi"}')])
self.assertEqual(transport.received_count, 1)
def test_stop_is_idempotent_and_marks_stopped(self):
transport = self.transport()
transport.start()
self.client.fire_connect(0)
transport.stop()
self.assertEqual(transport.state, mqtt_mod.STATE_STOPPED)
self.assertIn(("disconnect",), self.client.calls)
self.assertIn(("loop_stop",), self.client.calls)
# 主动断开后的 on_disconnect 不该被当成"掉线重连中"
self.client.fire_disconnect(0)
self.assertEqual(transport.state, mqtt_mod.STATE_STOPPED)
def test_tls_config_sets_tls(self):
transport = self.transport(tls=True, port=8883)
transport.start()
self.assertIsNotNone(self.client.tls)
self.assertIn(("connect_async", "127.0.0.1", 8883, 30), self.client.calls)
class BrokerTest(VFSTestCase):
"""端到端:真 paho ↔ 自建最小 broker ↔ 统一总线。"""
ORG = "acme"
USER = "alice"
def setUp(self):
super().setUp()
if not HAVE_PAHO:
self.skipTest("没装 paho-mqtt,跳过真实 MQTT 端到端")
self.broker = _MiniBroker()
self.addCleanup(self.broker.close)
self.bus = bus_mod.MessageBus(org=self.ORG, user=self.USER)
self.transport = mqtt_mod.MqttTransport(
config(host="127.0.0.1", port=self.broker.port, keepalive=5),
on_message=self.bus.deliver)
self.addCleanup(self.transport.stop)
self.assertEqual(self.transport.start(), mqtt_mod.STATE_CONNECTING)
self.assertTrue(wait_until(lambda: self.transport.is_connected()),
"客户端没能连上自建 broker")
def test_server_push_lands_in_bus(self):
# broker 记录客户端订阅的主题(验证最小权限订阅)
self.assertTrue(wait_until(lambda: len(self.broker.subscriptions) >= 2))
self.assertEqual(sorted(self.broker.subscriptions),
["svfs/acme/broadcast/#", "svfs/acme/user/alice/#"])
# 服务端主动推一条主机消息 + 一条插件消息
self.broker.publish("svfs/acme/user/alice/message",
json.dumps({"id": "m1", "title": "备份完成",
"body": "3 个包"}))
self.broker.publish("svfs/acme/user/alice/plugin/com.example.dash",
json.dumps({"cpu": 0.42}))
self.assertTrue(wait_until(lambda: len(self.bus.log) == 1))
self.assertTrue(wait_until(lambda: self.bus.pending("com.example.dash") == 1))
message = self.bus.log.items()[0]
self.assertEqual((message["title"], message["body"]), ("备份完成", "3 个包"))
self.assertTrue(message["unread"])
# 插件消息没有混进消息中心
self.assertEqual(len(self.bus.log), 1)
self.assertEqual(self.bus.drain("com.example.dash")[0]["payload"],
json.dumps({"cpu": 0.42}))
def test_qos1_style_redelivery_is_deduped_end_to_end(self):
payload = json.dumps({"id": "m2", "body": "同一条"})
self.broker.publish("svfs/acme/user/alice/message", payload)
self.broker.publish("svfs/acme/user/alice/message", payload)
self.assertTrue(wait_until(lambda: len(self.bus.log) == 1))
time.sleep(0.2)
self.assertEqual(len(self.bus.log), 1, "重投的两条应该去重成一条")
self.assertEqual(self.bus.stats()["duplicate"], 1)
def test_other_users_message_is_dropped_by_bus(self):
"""即使 broker 的 ACL 配错、把别人的消息推过来,总线也挡掉。
正常情况根本到不了这一步:客户端只订阅 ``user/<自己>/#``,broker 不会
投别的用户的消息(上一条测试验证的正是这个)。这里用 publish_raw 模拟
配错的 broker,验证第二道防线。
"""
self.broker.publish_raw("svfs/acme/user/bob/message", '{"body": "别人的"}')
self.assertTrue(wait_until(lambda: self.bus.stats().get("forbidden", 0) == 1))
self.assertEqual(len(self.bus.log), 0)
def test_other_orgs_message_is_dropped_by_bus(self):
self.broker.publish_raw("svfs/othercorp/user/alice/message",
'{"body": "别的租户"}')
self.assertTrue(wait_until(lambda: self.bus.stats().get("forbidden", 0) == 1))
self.assertEqual(len(self.bus.log), 0)
# ----------------------------------------------------------------------
# 最小 MQTT broker(只为测试:MQTT 3.1.1、QoS 0、CONNECT/SUBSCRIBE/PUBLISH/
# PINGREQ/DISCONNECT)。故意不引第三方 broker —— 测试要能离线跑。
# ----------------------------------------------------------------------
class _MiniBroker:
"""够用的 MQTT broker:接连接、记订阅、按通配符投递 PUBLISH。"""
def __init__(self):
self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self._server.bind(("127.0.0.1", 0))
self._server.listen(4)
self.port = self._server.getsockname()[1]
self.subscriptions = [] # broker 收到的订阅(测试断言用)
self.published = [] # broker 收到的客户端 PUBLISH(本测试没用到)
self._clients = [] # [(sock, [topics])]
self._lock = threading.Lock()
self._closed = False
threading.Thread(target=self._accept_loop, daemon=True).start()
# ---- 对外 -------------------------------------------------------
def publish(self, topic, payload):
"""broker 主动推一条(QoS 0),按订阅投递。"""
self._send(topic, payload, ignore_subscriptions=False)
def publish_raw(self, topic, payload):
"""无视订阅、推给所有连接(模拟 ACL 配错的 broker)。
正常 broker 不会这么干(订阅本身就是 ``user/<自己>/#``),但"broker
配错"是真实存在的运维事故,总线的归属校验就是为它准备的兜底。
"""
self._send(topic, payload, ignore_subscriptions=True)
def _send(self, topic, payload, ignore_subscriptions):
body = payload.encode("utf-8") if isinstance(payload, str) else bytes(payload)
packet = _publish_packet(topic, body)
with self._lock:
if ignore_subscriptions:
targets = [sock for sock, _filters in self._clients]
else:
targets = [sock for sock, filters in self._clients
if any(_topic_matches(f, topic) for f in filters)]
for sock in targets:
try:
sock.sendall(packet)
except OSError:
pass
def close(self):
self._closed = True
with self._lock:
for sock, _filters in self._clients:
try:
sock.close()
except OSError:
pass
self._clients = []
try:
self._server.close()
except OSError:
pass
# ---- 内部 -------------------------------------------------------
def _accept_loop(self):
while not self._closed:
try:
sock, _addr = self._server.accept()
except OSError:
return
with self._lock:
self._clients.append((sock, []))
threading.Thread(target=self._client_loop, args=(sock,),
daemon=True).start()
def _client_loop(self, sock):
try:
while not self._closed:
header = sock.recv(1)
if not header:
return
packet_type = header[0] >> 4
length, _ = _read_length(sock)
body = _read_exact(sock, length) if length else b""
if packet_type == 1: # CONNECT
sock.sendall(b"\x20\x02\x00\x00")
elif packet_type == 8: # SUBSCRIBE
packet_id = body[:2]
filters = _parse_subscribe(body[2:])
with self._lock:
for entry in self._clients:
if entry[0] is sock:
entry[1].extend(filters)
self.subscriptions.extend(filters)
sock.sendall(b"\x90" + bytes([2 + len(filters)]) + packet_id
+ b"\x00" * len(filters))
elif packet_type == 3: # PUBLISH(客户端发的)→ 转投订阅者
topic, payload = _parse_publish(body)
self.published.append(topic)
self._send(topic, payload, ignore_subscriptions=False)
elif packet_type == 12: # PINGREQ
sock.sendall(b"\xd0\x00")
elif packet_type == 14: # DISCONNECT
return
except OSError:
return
finally:
with self._lock:
self._clients = [entry for entry in self._clients if entry[0] is not sock]
try:
sock.close()
except OSError:
pass
def _read_length(sock):
"""MQTT 的可变长度字段(剩下长度)。"""
multiplier = 1
value = 0
for _ in range(4):
byte = sock.recv(1)
if not byte:
raise OSError("连接已关闭")
digit = byte[0]
value += (digit & 0x7F) * multiplier
if not digit & 0x80:
break
multiplier *= 128
return value, multiplier
def _read_exact(sock, count):
chunks = []
remaining = count
while remaining > 0:
chunk = sock.recv(remaining)
if not chunk:
raise OSError("连接已关闭")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def _parse_subscribe(body):
"""SUBSCRIBE 载荷:包 id(2) + 若干个 [主题长度(2) + 主题 + QoS(1)]。"""
filters = []
offset = 0
while offset + 2 <= len(body):
length = struct.unpack(">H", body[offset:offset + 2])[0]
start = offset + 2
filters.append(body[start:start + length].decode("utf-8"))
offset = start + length + 1 # 跳过 QoS 字节(别忘了,否则后面全错位)
return filters
def _parse_publish(body):
length = struct.unpack(">H", body[:2])[0]
topic = body[2:2 + length].decode("utf-8")
return topic, body[2 + length:]
def _publish_packet(topic, payload):
encoded = topic.encode("utf-8")
body = struct.pack(">H", len(encoded)) + encoded + payload
return b"\x30" + _encode_length(len(body)) + body
def _encode_length(length):
out = bytearray()
while True:
digit = length % 128
length //= 128
if length:
digit |= 0x80
out.append(digit)
if not length:
return bytes(out)
def _topic_matches(topic_filter, topic):
"""主题过滤器匹配(支持 ``+`` 与末尾的 ``#``)。"""
filter_parts = topic_filter.split("/")
topic_parts = topic.split("/")
for index, part in enumerate(filter_parts):
if part == "#":
return True
if index >= len(topic_parts):
return False
if part != "+" and part != topic_parts[index]:
return False
return len(filter_parts) == len(topic_parts)
if __name__ == "__main__":
unittest.main()
+537 -11
View File
@@ -15,6 +15,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("QTWEBENGINE_CHROMIUM_FLAGS", "--no-sandbox --disable-gpu")
import json
import gc
import sys
import tempfile
import threading
@@ -27,8 +28,8 @@ from helpers import VFSTestCase, make_tree, quiet
from sqlite_vfs.core import SQLiteVFS
try:
from PySide6.QtCore import QSettings, Qt, QUrl
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QCoreApplication, QEvent, QSettings, Qt, QTimer, QUrl
from PySide6.QtWidgets import QApplication, QDialog, QWidget
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 - WebEngine 探测
HAVE_QT = True
@@ -108,6 +109,36 @@ class _ApiHandler(BaseHTTPRequestHandler):
pass
class _FakeLoginWindow:
"""登录窗替身:``exec()`` 立刻返回,``session`` 就是预设的会话(None = 用户关掉了窗)。
``visible_probe`` 在 exec() 期间被调用一次,用来断言"进登录门时主窗口已经
关掉了"这件事——真去驱动一个模态登录窗要绕一大圈(见
test_client_login_ui.py 里的 QTimer 轮询写法),这里只需要登录门的时序。
"""
def __init__(self, session, visible_probe=None):
self.session = session
self.shown = False
self.raised = False
self._visible_probe = visible_probe
self.main_window_visible_during = None
def show(self):
self.shown = True
def raise_(self):
self.raised = True
def activateWindow(self): # noqa: N802 - 模拟 Qt 命名
pass
def exec(self):
if self._visible_probe is not None:
self.main_window_visible_during = self._visible_probe()
return 0
def start_api_server():
server = ThreadingHTTPServer(("127.0.0.1", 0), _ApiHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
@@ -152,6 +183,30 @@ if HAVE_QT:
_APP = QApplication.instance() or create_app([sys.argv[0]])
_APP.setQuitOnLastWindowClosed(False)
def tearDownModule():
"""退出前把 WebEngine 对象主动拆掉(否则进程会以段错误收场)。
留着窗口不管,Chromium 的 profile / 渲染子进程就要等解释器退出时才拆,
而 offscreen 下那条路径会以 access violation 结束——测试全绿、退出码却是
139,CI 照样判失败(本机 3.13 + PySide6 6.11.2 实测)。close() 走的是
MainWindow.closeEvent 那条正常销毁顺序:收插件页面 → 停工作线程 → 删
profile;这里再跑一轮 deferred delete 让 deleteLater 真正落地。
"""
global _APP
# 收掉本模块残留的窗口,让各窗口自己的 WebEngine 资源在**这里**释放,
# 而不是全堆到解释器退出时一起析构。注意别在这里 gc.collect() 或
# sendPostedEvents:那会把几十个 profile 的析构挤到同一时刻,实测直接崩。
#
# 即便这样,解释器退出路径在 offscreen + WebEngine 下仍会段错误(与
# 被测代码无关),所以测试统一走 tests/run_tests.py —— 它按 unittest 的
# 结果 os._exit,绕开那条会崩的清理路径。
for widget in list(QApplication.topLevelWidgets()):
widget.close()
widget.deleteLater()
for _ in range(30):
_APP.processEvents()
time.sleep(0.01)
class BridgeTest(VFSTestCase):
"""桥的权限模型与存储隔离(不经过 WebView)。"""
@@ -517,8 +572,11 @@ if HAVE_QT:
self.assertTrue(pump(_APP, predicate=lambda: self.window._startup_resolved))
def tearDown(self):
self.window._thread.quit()
self.window._thread.wait(5000)
# 必须走 close() 而不是直接 deleteLater():只有 closeEvent 会拆掉
# WebEngine profile、协议层句柄与工作线程。不拆的话它们全留到
# 解释器退出时一起析构,而 offscreen 下那条路径会崩(实测)。
# 每个用例各拆各的,也避免了"几十个 profile 同时析构"。
self.window.close()
self.window.deleteLater()
pump(_APP)
super().tearDown()
@@ -780,7 +838,8 @@ if HAVE_QT:
self._install_extra_plugin(other)
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
# 默认规则:无配置、无 last_plugin → 按 id 排序第一个启用的插件
# 默认规则:无配置、无 last_plugin → 显示顺序里第一个启用的插件
# (默认顺序 = 按名称:"Other" 在 "SVFS 桥调试台" 前面)
self.assertEqual(
self.window._resolve_startup_plugin(), min(pid, other)
)
@@ -808,6 +867,151 @@ if HAVE_QT:
self.assertIsNone(self.window.set_startup_plugin(""))
_ui_settings().remove("ui/last_plugin")
def test_plugin_order_arrangement(self):
"""编排插件顺序:↑/↓ 改显示顺序并落盘,侧栏/管理页/启动默认都跟随。"""
from svfs_client import settings as client_settings
from svfs_client.shell import _ui_settings
_ui_settings().remove("ui/last_plugin")
self.window.set_startup_plugin("")
self.window.apply_payload(self._submit_and_wait("install_sample", {}))
other = "com.other.app"
self._install_extra_plugin(other)
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
ids = [entry["id"] for entry in self.window._plugins]
self.assertEqual(len(ids), 2, "多插件场景没建起来,用例前提不成立")
self.assertEqual(client_settings.plugin_order(self.base), [])
self.assertFalse(self.window.manager.order_reset_button.isVisible())
top, bottom = ids[0], ids[-1]
# 边界:首位不能上移、末位不能下移
self.assertFalse(
self.window.manager.cards[top].up_button.isEnabled())
self.assertFalse(
self.window.manager.cards[bottom].down_button.isEnabled())
self.assertTrue(
self.window.manager.cards[bottom].up_button.isEnabled())
# 上移一位:列表顺序、侧栏顺序、落盘的顺序三者一致
self.window.manager.cards[bottom].up_button.click()
pump(_APP)
self.assertEqual([entry["id"] for entry in self.window._plugins],
[bottom, top])
self.assertEqual(client_settings.plugin_order(self.base),
[bottom, top])
sidebar = [
self.window.plugin_list.item(row).data(Qt.ItemDataRole.UserRole)
for row in range(self.window.plugin_list.count())
]
self.assertEqual(sidebar, [bottom, top])
# 编排过 → "恢复默认顺序"出现
self.assertTrue(self.window.manager.order_reset_button.isVisible())
# 启动直显的默认规则跟着编排走(不再按 id/名称)
self.assertEqual(self.window._resolve_startup_plugin(), bottom)
# worker 重新拉列表时也按编排顺序(不是只有界面上排了一下)
self.window.apply_payload(self._submit_and_wait("list_plugins", {}))
self.assertEqual([entry["id"] for entry in self.window._plugins],
[bottom, top])
# 恢复默认顺序:回到按名称("Other" 在前)、设置清空、按钮消失
self.window.manager.order_reset_button.click()
pump(_APP)
self.assertEqual(client_settings.plugin_order(self.base), [])
self.assertEqual([entry["id"] for entry in self.window._plugins],
[other, client_sample.PLUGIN_ID])
self.assertFalse(self.window.manager.order_reset_button.isVisible())
# 收尾:别把启动配置留给后续用例
self.window.set_startup_plugin("")
_ui_settings().remove("ui/last_plugin")
def test_settings_dialog_mqtt_config(self):
"""设置里配 broker:落盘 + 立即重连(不用等下次登录)。"""
from svfs_client.dialogs import SettingsDialog
self.window.set_session({"username": "admin", "token": "tok"})
applied = []
dialog = SettingsDialog(
self.base, apply_base_url=self.window.set_base_url,
session_provider=lambda: self.window._session,
on_mqtt_settings_changed=lambda: (applied.append(1),
self.window._restart_messaging()),
)
self.addCleanup(dialog.deleteLater)
# 没配 broker、服务端也没下发 → 显示"未启用"
self.assertIn("未启用", dialog.mqtt_status.text())
dialog.mqtt_host_edit.setText("mqtts://broker.acme:8883")
dialog.mqtt_prefix_edit.setText("acme")
dialog.org_edit.setText("acme")
dialog.mqtt_user_edit.setText("svc-client")
dialog._save_mqtt_settings()
pump(_APP)
# 落盘 + 回调(重连)
data = client_settings.load(self.base)
self.assertEqual(data["mqtt_host"], "mqtts://broker.acme:8883")
self.assertEqual(data["mqtt_prefix"], "acme")
self.assertEqual(data["org"], "acme")
self.assertEqual(data["mqtt_username"], "svc-client")
self.assertEqual(applied, [1])
# 壳层确实按新配置重连了(tls 与端口由 URL 推出来)
self.assertEqual(self.window.mqtt.config["host"], "broker.acme")
self.assertEqual(self.window.mqtt.config["port"], 8883)
self.assertTrue(self.window.mqtt.config["tls"])
# 状态行显示生效配置
self.assertIn("broker.acme", dialog.mqtt_status.text())
self.assertIn("已保存并重连", dialog.mqtt_status.text())
# 清空 broker 地址 = 回到"不启用推送"(同步仍然工作)
dialog.mqtt_host_edit.setText("")
dialog._save_mqtt_settings()
pump(_APP)
self.assertFalse(self.window.mqtt.config["enabled"])
def test_settings_dialog_shows_server_announced_broker(self):
"""服务端下发的 broker 也算生效配置(设置里留空时状态行要说明来源)。"""
from svfs_client.dialogs import SettingsDialog
session = {"username": "admin", "token": "tok", "org": "acme",
"mqtt": {"host": "broker.acme", "port": 8883, "tls": True}}
dialog = SettingsDialog(
self.base, apply_base_url=self.window.set_base_url,
session_provider=lambda: session,
)
self.addCleanup(dialog.deleteLater)
self.assertIn("broker.acme", dialog.mqtt_status.text())
self.assertIn("服务端下发", dialog.mqtt_status.text())
def test_settings_dialog_tabs_group_by_type(self):
"""设置项按类型分页签,而不是堆成一长条。"""
from svfs_client.dialogs import SettingsDialog
dialog = SettingsDialog(self.base, apply_base_url=self.window.set_base_url)
self.addCleanup(dialog.deleteLater)
self.assertEqual(
[dialog.tabs.tabText(i) for i in range(dialog.tabs.count())],
["插件", "服务器", "存储", "网络", "诊断"])
# 默认停在第一页:别的页上的控件此刻不可见
self.assertTrue(dialog.startup_combo.isVisibleTo(dialog))
self.assertFalse(dialog.server_edit.isVisibleTo(dialog))
# 各控件待在各自那一页
for title, attr in (("服务器", "server_edit"), ("服务器", "mqtt_host_edit"),
("服务器", "credentials_label"), ("存储", "cache_edit"),
("存储", "cache_label"), ("网络", "proxy_edit"),
("网络", "timeout_spin"), ("诊断", "log_check"),
("插件", "startup_combo")):
self.assertTrue(dialog.show_tab(title), title)
widget = getattr(dialog, attr)
self.assertTrue(widget.isVisibleTo(dialog),
f"{attr} 不在「{title}」页签上")
# 找不到的页签:不炸,返回 False
self.assertFalse(dialog.show_tab("不存在的页"))
def test_settings_dialog_startup_and_cache(self):
from svfs_client.dialogs import SettingsDialog
@@ -1072,6 +1276,197 @@ if HAVE_QT:
self.assertEqual(self.window.status_label.text(), "服务端返回 500")
self.assertTrue(self.window.manager.banner.isVisible())
@staticmethod
def _white_pixels(image, x0, y0, width, height):
"""数一块区域里的近白像素(扫原始字节,比逐点 pixelColor 快很多)。"""
from PySide6.QtGui import QImage
if image.format() != QImage.Format.Format_RGB32:
image = image.convertToFormat(QImage.Format.Format_RGB32)
raw = bytes(image.constBits())
stride = image.bytesPerLine()
count = 0
for y in range(y0, y0 + height):
start = y * stride + x0 * 4
row = raw[start:start + width * 4]
for x in range(0, len(row), 4):
blue, green, red = row[x], row[x + 1], row[x + 2]
if red > 230 and green > 230 and blue > 230:
count += 1
return count
def test_collapsed_sidebar_leaves_no_white_gap(self):
"""收缩侧栏后不该留下没重绘的白块。
曾复现:主题只给 QMainWindow/QDialog 设了背景,页面栈是透明的,
侧栏让出的那一条在重绘时序里露出白底——表现为页面左上角一块
44×30 的纯白方块(`#ffffff`)。修法是给 QStackedWidget 不透明底色
(见 app.create_app 里的样式)。这条用例在图上一把尺子,两个页面都量。
"""
self.window._set_sidebar_collapsed(True, animate=False)
for page in (self.window.manager, self.window.messages_page):
self.window.stack.setCurrentWidget(page)
pump(_APP)
image = self.window.grab().toImage()
# 量侧栏右侧那片(白块出现的地方)——竖着量满高,横向量 300px
whites = self._white_pixels(image, self.window.sidebar.width(), 0,
300, image.height())
self.assertEqual(
whites, 0,
f"{type(page).__name__} 在收缩侧栏后出现了 {whites} 个近白像素")
self.window._set_sidebar_collapsed(False, animate=False)
def test_push_routes_host_and_plugin_messages(self):
"""同一条总线:主机消息进消息中心,插件消息只进插件队列。"""
from svfs_client import topics
self.window.set_session({"username": "admin", "token": "tok"})
bus = self.window.bus
host_topic = topics.user_message_topic(bus.prefix, bus.org, "admin")
plugin_topic = topics.user_plugin_topic(bus.prefix, bus.org, "admin",
"com.example.dash")
self.window._on_push(host_topic, '{"id": "m1", "title": "备份完成"}')
self.window._on_push(plugin_topic, '{"cpu": 0.5}')
pump(_APP)
self.assertEqual(len(bus.log), 1)
self.assertEqual(self.window.messages_page.cards_box.count(),
2) # 一张卡片 + 末尾 stretch
self.assertEqual(self.window.messages_button.text(), "✉1")
self.assertIn("1", self.window.messages_button.toolTip())
# 插件消息没进消息中心,而是留在队列里等页面打开
self.assertEqual(len(bus.log), 1)
self.assertEqual(bus.pending("com.example.dash"), 1)
self.assertEqual(self.window.messages_button.text(), "✉1")
def test_messages_center_mark_read_and_clear(self):
from svfs_client import topics
self.window.set_session({"username": "admin", "token": "tok"})
topic = topics.user_message_topic(self.window.bus.prefix,
self.window.bus.org, "admin")
for index in range(2):
self.window._on_push(topic, '{"id": "m%d", "body": "x"}' % index)
pump(_APP)
self.assertEqual(self.window.messages_button.text(), "✉2")
self.window._show_messages()
pump(_APP)
# 进消息中心即算读过(徽标不该一直挂着)
self.assertEqual(self.window.bus.log.unread(), 0)
self.assertEqual(self.window.messages_button.text(), "✉")
self.assertIs(self.window.stack.currentWidget(),
self.window.messages_page)
self.window.messages_page.clear_button.click()
pump(_APP)
self.assertEqual(len(self.window.bus.log), 0)
self.assertTrue(self.window.messages_page.empty.isVisible())
self.assertFalse(self.window.messages_page.read_button.isEnabled())
def test_plugin_push_is_buffered_until_page_opens(self):
"""插件页面没开时消息先攒着,页面建好后一次性补投。"""
from svfs_client import topics
self.window.set_session({"username": "admin", "token": "tok"})
bus = self.window.bus
topic = topics.user_plugin_topic(bus.prefix, bus.org, "admin", "com.dash")
self.window._on_push(topic, '{"cpu": 1}')
self.assertEqual(bus.pending("com.dash"), 1)
pushed = []
class _FakeView(QWidget):
def push(self, payload):
pushed.append(payload)
def shutdown(self): # close_plugin 会调(见 tearDownModule)
pass
self.window._views["com.dash"] = _FakeView()
self.window._flush_plugin_pushes("com.dash")
self.assertEqual(pushed, ['{"cpu": 1}'])
self.assertEqual(bus.pending("com.dash"), 0)
# 页面已经开着时是直接投递,不进队列
self.window._on_push(topic, '{"cpu": 2}')
self.assertEqual(pushed, ['{"cpu": 1}', '{"cpu": 2}'])
self.assertEqual(bus.pending("com.dash"), 0)
def test_login_sync_feeds_bus_and_dedupes_with_push(self):
"""登录后先同步:补齐的消息与推送走同一条总线、按 id 去重。"""
from svfs_client import topics
self.window.set_session({"username": "admin", "token": "tok"})
topic = topics.user_message_topic(self.window.bus.prefix,
self.window.bus.org, "admin")
# 推送先到一条,补齐(HTTP)又把同一条送回来 → 只留一条
self.window._on_push(topic, '{"id": "m1", "title": "备份完成"}')
self.window._on_messages_synced(
{"messages": [{"id": "m1", "title": "备份完成"},
{"id": "m2", "title": "另一条"}],
"fetched": 2}, "admin")
pump(_APP)
self.assertEqual([m["id"] for m in self.window.bus.log.items()],
["m1", "m2"])
self.assertEqual(len(self.window.messages_page.cards_box.count() and
self.window.bus.log.items()), 2)
# 没配服务器地址时同步不报错,只是拿不到东西(登录后的自动同步
# 在测试环境里走的正是这条)
self.window._pending_sync = False
result = self.window.client.call_blocking("sync_messages", {})
self.assertEqual(result["messages"], [])
self.assertFalse(result["fetched"])
def test_stale_sync_result_is_discarded(self):
"""在途的补齐结果在退出登录/换账号后必须丢掉(不能喂给新会话)。"""
self.window.set_session({"username": "admin", "token": "tok"})
self.window._on_messages_synced(
{"messages": [{"id": "m1", "body": "上一个人的消息"}]}, "admin")
self.assertEqual(len(self.window.bus.log), 1)
# 退出登录:身份没了 → 在途结果丢弃(这里不抛异常也算断言的一部分)
self.window.logout()
pump(_APP)
self.window._on_messages_synced(
{"messages": [{"id": "m9", "body": "迟到的"}]}, "admin")
self.assertEqual(len(self.window.bus.log), 0)
# 换了账号:同一条在途结果属于旧身份 → 也丢弃
self.window.set_session({"username": "bob", "token": "tok2"})
self.window._on_messages_synced(
{"messages": [{"id": "m9", "body": "迟到的"}]}, "admin")
self.assertEqual(len(self.window.bus.log), 0)
def test_logout_stops_push_and_clears_messages(self):
from svfs_client import topics
self.window.set_session({"username": "admin", "token": "tok"})
topic = topics.user_message_topic(self.window.bus.prefix,
self.window.bus.org, "admin")
self.window._on_push(topic, '{"id": "m1", "body": "x"}')
self.assertEqual(len(self.window.bus.log), 1)
self.window.logout()
pump(_APP)
# 上一个身份的消息不能留给下一个人看
self.assertEqual(len(self.window.bus.log), 0)
self.assertEqual(self.window.messages_button.text(), "✉")
# 断开后推来的东西也进不来(总线身份已清空)
self.assertEqual(
self.window.bus.deliver(topic, '{"body": "x"}')["action"],
"forbidden")
def test_mqtt_state_is_shown_in_message_center(self):
from svfs_client import mqtt
# 走服务自己的状态机:它先更新状态、再发信号(生产路径就是这样)
self.window.mqtt._on_state(mqtt.STATE_ERROR, "认证失败")
pump(_APP)
self.assertIn("连接失败", self.window.messages_page.transport_label.text())
self.assertIn("消息推送", self.window.status_label.text())
def test_diagnostics_log_collects_errors(self):
from svfs_client import diagnostics
@@ -1124,10 +1519,12 @@ if HAVE_QT:
self.window.manager.server_url_label.full_text(),
"http://10.0.0.9:9000",
)
# 非法地址:报错、不落盘
# 非法地址:报错、不落盘(服务器地址在"服务器"页签上)
dialog.show_tab("服务器")
dialog.server_edit.setText("ftp://nope")
dialog._apply_base_url_clicked()
self.assertTrue(dialog.server_error.isVisibleTo(dialog))
self.assertIn("http", dialog.server_error.text())
self.assertEqual(
client_settings.load(self.base)["base_url"], "http://10.0.0.9:9000"
)
@@ -1152,16 +1549,112 @@ if HAVE_QT:
self.assertEqual(dialog.state_badge.text(), "已登录")
self.assertTrue(dialog.logout_button.isEnabled())
self.assertTrue(dialog.relogin_button.objectName() != "Primary")
# 退出登录(真回调):会话清空、界面回到未登录态
# 退出登录(真回调):凭据/会话清空,对话框关掉自己并把界面去向
# 交给主窗口(登录门在那个模态循环外面跑)
self.assertFalse(dialog.logout_requested)
dialog.logout_button.click()
self.assertIsNone(self.window._session)
self.assertEqual(dialog.user_label.text(), "未登录")
self.assertFalse(dialog.logout_button.isEnabled())
self.assertEqual(dialog.relogin_button.objectName(), "Primary")
self.assertEqual(dialog.state_badge.text(), "未登录")
self.assertTrue(dialog.logout_requested)
self.assertEqual(dialog.result(), QDialog.DialogCode.Accepted)
# 侧栏头像同步回到「未登录」
self.assertEqual(self.window.user_button.text(), "未登录")
def test_logout_closes_window_and_returns_to_login(self):
"""退出登录 = 关主窗口 + 回登录界面(强制登录下没有"未登录的主窗口")。"""
from svfs_client import credentials as client_credentials
payload = self._submit_and_wait("install_sample", {})
self.window.apply_payload(payload)
self.window.open_plugin(client_sample.PLUGIN_ID)
pump(_APP)
self.assertTrue(self.window._views, "插件页面没建起来,用例前提不成立")
self.window.set_session({"username": "admin", "token": "tok"})
client_credentials.save(self.base, "tok",
base_url="http://127.0.0.1:1",
username="admin")
fake = _FakeLoginWindow({"username": "bob", "token": "new"},
visible_probe=lambda: self.window.isVisible())
self._patch_login_window(fake)
self.window._logout_with_gate()
pump(_APP)
# 凭据清干净、插件页面收掉(不该留着上一个账号的界面状态)
self.assertEqual(client_credentials.load(self.base), "")
self.assertEqual(self.window._views, {})
# 登录期间主窗口是关掉的,登录成功后带着新会话回来
self.assertFalse(fake.main_window_visible_during)
self.assertEqual(self.window._session["username"], "bob")
self.assertTrue(self.window.isVisible())
self.assertEqual(self.window.user_button.text(), "bob")
def test_logout_cancelled_login_exits_client(self):
"""用户关掉登录窗:强制登录下没有可去的界面 → 关窗口并退出客户端。"""
fake = _FakeLoginWindow(None,
visible_probe=lambda: self.window.isVisible())
self._patch_login_window(fake)
app = QApplication.instance()
quits = []
original_quit = app.quit
app.quit = lambda: quits.append(1)
self.addCleanup(setattr, app, "quit", original_quit)
self.window.set_session({"username": "admin", "token": "tok"})
self.window._logout_with_gate()
pump(_APP)
self.assertFalse(fake.main_window_visible_during)
self.assertFalse(self.window.isVisible())
self.assertEqual(quits, [1], "没有请求退出客户端")
self.assertIsNone(self.window._session)
def test_logout_with_real_login_window_cancelled(self):
"""真登录窗(不填表,直接关掉):主窗口关掉、客户端请求退出。
上面两条用替身验证登录门的时序;这条用真窗口走一遍
show/exec/close,确认"隐藏主窗口 + 模态登录窗"在真 Qt 对象上不炸。
"""
from svfs_client import login as client_login
self.window.set_session({"username": "admin", "token": "tok"})
app = QApplication.instance()
quits = []
original_quit = app.quit
app.quit = lambda: quits.append(1)
self.addCleanup(setattr, app, "quit", original_quit)
# 登录门是模态的(exec 阻塞),只能在嵌套循环里用定时器关掉它
seen = {"login": False}
def close_login():
for widget in QApplication.topLevelWidgets():
if isinstance(widget, client_login.LoginWindow):
seen["login"] = True
widget.reject()
return
timer = QTimer()
timer.setInterval(50)
timer.timeout.connect(close_login)
timer.start()
self.addCleanup(timer.stop)
self.window._logout_with_gate()
pump(_APP)
self.assertTrue(seen["login"], "退出登录后没有出现登录窗")
self.assertFalse(self.window.isVisible(), "主窗口没关掉")
self.assertEqual(quits, [1], "没有请求退出客户端")
self.assertIsNone(self.window._session)
def _patch_login_window(self, fake):
"""把登录门里的登录窗换成替身(登录门内部才 import,patch 源头即可)。"""
from svfs_client import login as client_login
original = client_login.LoginWindow
client_login.LoginWindow = lambda *args, **kwargs: fake
self.addCleanup(setattr, client_login, "LoginWindow", original)
if HAVE_QT and os.environ.get("SVFS_CLIENT_WEBENGINE_SMOKE", "1") != "0":
@@ -1323,6 +1816,39 @@ if HAVE_QT and os.environ.get("SVFS_CLIENT_WEBENGINE_SMOKE", "1") != "0":
self.assertIn("pong", net_result["body"])
self.assertIsNone(window.set_base_url(""))
# 消息通道端到端(页面级):插件订阅 → 宿主推送 → 回调收到。
# 这条覆盖 v5 的 messages 能力(页面里的 svfs.messages 与
# window.__svfsPush 的投递路径)
msgs = {}
page.runJavaScript(
"(function(){ window.__msg=null;"
" if (!window.svfs || !window.svfs.messages) { return 'no-api'; }"
" window.svfs.messages.subscribe(function (m) {"
" window.__msg = m.text; });"
" return 'ok'; })()",
0, lambda r: msgs.update(api=r),
)
self.assertTrue(
pump(_APP, predicate=lambda: msgs.get("api") is not None,
timeout=15),
"messages 桥探针没有拿到结果",
)
self.assertEqual(msgs.get("api"), "ok",
"示例插件声明了 messages 权限,却拿不到 svfs.messages")
view.push('{"kind": "dash", "value": 7}')
def pushed_ready():
page.runJavaScript("window.__msg", 0,
lambda r: msgs.update(received=r))
return msgs.get("received") is not None
self.assertTrue(
pump(_APP, predicate=pushed_ready, timeout=15),
f"推送没有到达插件页面: {msgs}",
)
self.assertIn("dash", msgs["received"])
# 渲染进程崩溃 → 崩溃占位页(而不是永久白屏)
from svfs_client import diagnostics
+183
View File
@@ -34,6 +34,14 @@ if HAVE_FASTAPI:
from svfs_client import paths as client_paths
from svfs_client import sample as client_sample
try:
# 自建最小 broker(见 test_client_mqtt)——端到端用例要一个真 broker,
# 但测试不应该依赖装 mosquitto
from test_client_mqtt import _MiniBroker, wait_until
HAVE_BROKER = True
except Exception:
HAVE_BROKER = False
_load_seq = [0]
def _load_example(plugins_dir):
@@ -104,6 +112,181 @@ if HAVE_FASTAPI:
self.assertEqual(response.status_code, 401)
self.assertIn("不正确", response.json()["detail"])
def test_message_push_and_sync(self):
"""服务端主动推消息:进该用户的待收列表,客户端登录后能补齐。"""
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "message", "title": "备份完成", "body": "3 个包",
"level": "warn"})
self.assertEqual(response.status_code, 200, response.text)
body = response.json()
# 主题按方案拼(组织位在第一段之后,多租户就靠它隔离)
self.assertEqual(body["topic"], "svfs/default/user/admin/message")
self.assertEqual(body["message"]["title"], "备份完成")
self.assertTrue(body["message"]["id"])
self.assertTrue(body["stored"])
# 没配 broker:如实说明是跳过了,不是假装发成功
self.assertIn("skipped", body["mqtt"])
# 补齐:默认全给;给了 since 只给更新的
response = self.client.get("/api/messages", headers=headers)
self.assertEqual(len(response.json()["messages"]), 1)
newest = response.json()["messages"][0]["time"]
response = self.client.get(f"/api/messages?since={newest}",
headers=headers)
self.assertEqual(response.json()["messages"], [])
# 发给别人的消息进别人的待收列表(自己的列表不受影响)
response = self.client.post("/api/push", headers=headers,
json={"title": "给别人的", "user": "bob"})
self.assertEqual(response.json()["topic"],
"svfs/default/user/bob/message")
self.assertEqual(
len(self.client.get("/api/messages", headers=headers)
.json()["messages"]), 1)
def test_plugin_push_goes_to_plugin_topic_and_not_backlog(self):
"""插件消息走插件主题、**不进**待收列表(仪表盘数据补历史没意义)。"""
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "plugin", "plugin": "com.svfs.demo", "data": {"cpu": 0.42}})
self.assertEqual(response.status_code, 200, response.text)
body = response.json()
self.assertEqual(body["topic"],
"svfs/default/user/admin/plugin/com.svfs.demo")
self.assertFalse(body["stored"])
self.assertEqual(
self.client.get("/api/messages", headers=headers).json()["messages"],
[])
# 少了 plugin 字段:400,别猜
response = self.client.post("/api/push", headers=headers,
json={"kind": "plugin"})
self.assertEqual(response.status_code, 400)
def test_broadcast_and_org_announcement(self):
"""广播走组织主题;登录响应下发组织标识(多租户预留位)。"""
body = self._login().json()
self.assertEqual(body["org"], "default")
self.assertNotIn("mqtt", body) # 没配 broker 就不下发
headers = {"Authorization": f"Bearer {body['token']}"}
response = self.client.post("/api/broadcast", headers=headers,
json={"title": "全员通知", "body": "维护"})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()["topic"],
"svfs/default/broadcast/message")
def test_org_and_broker_announcement_follows_env(self):
"""服务端配了组织/broker:登录与 /api/me 都下发(客户端据此订阅)。"""
keys = ("SVFS_ORG", "SVFS_MQTT_HOST", "SVFS_MQTT_PORT", "SVFS_MQTT_TLS")
old = {key: os.environ.get(key) for key in keys}
def restore():
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(restore)
os.environ.update({"SVFS_ORG": "acme", "SVFS_MQTT_HOST": "broker.acme",
"SVFS_MQTT_PORT": "8883", "SVFS_MQTT_TLS": "true"})
body = self._login().json()
self.assertEqual(body["org"], "acme")
self.assertEqual(body["mqtt"], {"host": "broker.acme", "tls": True,
"port": 8883, "prefix": "svfs",
"org": "acme"})
headers = {"Authorization": f"Bearer {body['token']}"}
self.assertEqual(
self.client.get("/api/me", headers=headers).json()["org"], "acme")
# 主题里的组织段跟着变(多租户隔离就靠它)
response = self.client.post("/api/push", headers=headers,
json={"title": "x"})
self.assertEqual(response.json()["topic"],
"svfs/acme/user/admin/message")
def test_push_requires_auth(self):
self.assertEqual(
self.client.post("/api/push", json={"title": "x"}).status_code, 401)
self.assertEqual(self.client.get("/api/messages").status_code, 401)
@unittest.skipUnless(HAVE_BROKER, "需要 paho-mqtt 与自建 broker 助手")
def test_server_push_reaches_client_over_mqtt(self):
"""端到端:``/api/push`` → broker → 客户端 MQTT 通道 → 消息中心。
这条链路把三方接在一起验证:**broker 地址由登录响应下发**(客户端
不用手配)、服务端按主题方案发布、客户端用同一套主题方案订阅并让
消息落进总线。
"""
from svfs_client import settings as client_settings
from svfs_client.bus import MessageBus
from svfs_client.mqtt import MqttTransport
broker = _MiniBroker()
self.addCleanup(broker.close)
keys = ("SVFS_MQTT_HOST", "SVFS_MQTT_PORT", "SVFS_ORG")
old = {key: os.environ.get(key) for key in keys}
def restore():
for key, value in old.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
self.addCleanup(restore)
os.environ.update({"SVFS_MQTT_HOST": "127.0.0.1",
"SVFS_MQTT_PORT": str(broker.port),
"SVFS_ORG": "acme"})
session = self._login().json()
self.assertEqual(session["mqtt"]["host"], "127.0.0.1")
self.assertEqual(session["org"], "acme")
# 客户端侧:与服务端同一套主题方案 + 同一个解析器
base = client_paths.ensure_layout(self.path("data"))
config = client_settings.mqtt_config(base, session)
self.assertTrue(config["enabled"])
self.assertEqual(config["org"], "acme")
bus = MessageBus(org=config["org"], user=session["username"])
transport = MqttTransport(config, on_message=bus.deliver)
self.addCleanup(transport.stop)
transport.start()
self.assertTrue(wait_until(lambda: transport.is_connected()),
"客户端没有连上 broker")
# 服务端推一条 → 经 broker 到客户端
headers = {"Authorization": f"Bearer {session['token']}"}
response = self.client.post("/api/push", headers=headers, json={
"kind": "message", "title": "备份完成", "body": "3 个包"})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(wait_until(lambda: len(bus.log) == 1),
"推送没有到达客户端,消息中心是空的")
message = bus.log.items()[0]
self.assertEqual(message["title"], "备份完成")
self.assertEqual(message["scope"], "user")
# 插件消息走另一条通道:不进消息中心,只进该插件的队列
response = self.client.post("/api/push", headers=headers, json={
"kind": "plugin", "plugin": "com.svfs.demo", "data": {"cpu": 0.5}})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(
wait_until(lambda: bus.pending("com.svfs.demo") == 1),
"插件消息没有到达插件队列")
self.assertEqual(len(bus.log), 1)
# 组织广播:进消息中心并带 broadcast 标记
response = self.client.post("/api/broadcast", headers=headers,
json={"title": "全员通知"})
self.assertEqual(response.json()["mqtt"], "published")
self.assertTrue(wait_until(lambda: len(bus.log) == 2))
self.assertEqual(bus.log.items()[1]["scope"], "broadcast")
def test_me_and_logout(self):
token = self._login().json()["token"]
headers = {"Authorization": f"Bearer {token}"}