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:
@@ -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()
|
||||
@@ -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):
|
||||
"""诊断日志:环形缓冲、报告、自身绝不抛异常。"""
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
Reference in New Issue
Block a user