feat: 插件化桌面客户端 svfs-client(含凭据/会话/网络/日志的脚手架补齐)
一个插件 = 一个完整的 .svfs 包:客户端把插件前端经 svfs://<插件id>/ 直接加载 进 WebView(不落盘、不起端口、不解包),每个插件一个独立 origin(浏览器按 origin 隔离 localStorage)与独立键值存储;快照即版本——升级 = 派生快照的增量 打包,回滚 = 把注册表指针切回旧快照,包文件一个字节不用动。svfs_client 复用 svfs_studio 的主题与基础部件,故两个包一并入库。 按评估结论补齐的 P0 三块: * 会话:桥在"宿主注入的 token 被 401 拒绝"时通知壳层,提示一次并给出一键 「重新登录…」;服务器状态灯把「在线但登录失效」(黄)与「离线」(红)分开, 不再把凭据失效误报成网络不可达; * 凭据:新增 credentials.py——token 不再明文进 settings.json。Windows 走 DPAPI (密文只有当前用户在本机解得开),其它平台走系统凭据库(可选依赖 keyring), 都没有才明文兜底并在「设置 → 登录凭据」如实提示;令牌与签发它的 base_url 绑定(换服务器不会把旧令牌发出去);老字段 auth_token 启动时自动迁移; * 强登录链路保持:启动动画 → 静默校验 → 登录窗 → 主窗口,不可达时离线宽限。 P1/P2:net 超时与重试(只对幂等方法,避免重发 POST 变两单)可配、代理与 自签 CA 可配(loopback 永远直连);诊断日志滚动落盘(1MB×3,可关)——崩溃后 还能回看现场;客户端自更新检查(静态 <base_url>/client/manifest.json,缺失 即静默);插件清单可选 net_hosts 白名单;notify 改走系统托盘(无托盘降级状态栏); 设置界面新增网络 / 登录凭据 / 诊断日志三块。 examples/:示例服务端(FastAPI,含登录、上传即 publish、客户端版本接口)、 Vue 3 插件模板(带浏览器版宿主模拟器)、svfs-plugins-root/ 为模板配套的演示 插件实体。测试:tests/test_client_*.py(纯层 + Qt 层 + 一次真实 WebEngine 冒烟)、 tests/test_studio_*.py、tests/test_plugin_server.py。
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
#!/usr/bin/env python3
|
||||
"""svfs_client 纯标准库层的登录认证测试(不 import Qt)。
|
||||
|
||||
用本地 http.server 起一个假服务端(行为可用类属性调整),覆盖
|
||||
auth.login / verify_token / logout 的成功与各类失败,以及 settings
|
||||
新增键(auth_token / auth_user)的持久化。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from helpers import VFSTestCase
|
||||
|
||||
from svfs_client import auth, netutil
|
||||
from svfs_client import paths as client_paths
|
||||
from svfs_client import settings as client_settings
|
||||
|
||||
|
||||
class _AuthApiHandler(BaseHTTPRequestHandler):
|
||||
"""假服务端:/api/login、/api/me、/api/logout;``mode`` 可注入异常行为。"""
|
||||
|
||||
users = {"admin": "admin123"}
|
||||
sessions = {}
|
||||
mode = {}
|
||||
|
||||
def _bearer(self):
|
||||
header = self.headers.get("Authorization") or ""
|
||||
return header[7:].strip() if header.startswith("Bearer ") else ""
|
||||
|
||||
def _reply(self, code, payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _read_json(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
try:
|
||||
return json.loads(self.rfile.read(length) or b"{}")
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
data = self._read_json()
|
||||
if self.path == "/api/login":
|
||||
if self.mode.get("login") == "badjson":
|
||||
raw = b"this is not json"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
return
|
||||
username = str(data.get("username") or "")
|
||||
if username and self.users.get(username) == data.get("password"):
|
||||
token = f"tok-{username}-{len(self.sessions)}"
|
||||
self.sessions[token] = {
|
||||
"username": username,
|
||||
"login_time": "2026-09-15T10:00:00",
|
||||
}
|
||||
if self.mode.get("login") == "notoken":
|
||||
self._reply(200, {"ok": True, "username": username})
|
||||
else:
|
||||
self._reply(200, {"ok": True, "token": token,
|
||||
"username": username,
|
||||
"login_time": "2026-09-15T10:00:00"})
|
||||
else:
|
||||
self._reply(401, {"detail": "用户名或密码不正确"})
|
||||
elif self.path == "/api/logout":
|
||||
self.sessions.pop(self._bearer(), None)
|
||||
self._reply(200, {"ok": True})
|
||||
else:
|
||||
self._reply(404, {"detail": "not found"})
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path == "/api/me":
|
||||
session = self.sessions.get(self._bearer())
|
||||
if session:
|
||||
self._reply(200, {"ok": True, **session})
|
||||
else:
|
||||
self._reply(401, {"detail": "未登录或登录已过期"})
|
||||
else:
|
||||
self._reply(404, {"detail": "not found"})
|
||||
|
||||
def log_message(self, *args): # 静音
|
||||
pass
|
||||
|
||||
|
||||
class AuthApiTest(VFSTestCase):
|
||||
"""auth 模块对真实 HTTP 的行为(经本地假服务端)。"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_AuthApiHandler.sessions.clear()
|
||||
_AuthApiHandler.mode.clear()
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), _AuthApiHandler)
|
||||
thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(self.server.shutdown)
|
||||
self.addCleanup(self.server.server_close)
|
||||
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
|
||||
|
||||
def test_login_success_normalizes_base_url(self):
|
||||
session = auth.login(self.base + "/", "admin", "admin123")
|
||||
self.assertTrue(session["token"])
|
||||
self.assertEqual(session["username"], "admin")
|
||||
self.assertEqual(session["base_url"], self.base)
|
||||
self.assertEqual(session["login_time"], "2026-09-15T10:00:00")
|
||||
|
||||
def test_login_bad_password(self):
|
||||
with self.assertRaises(auth.AuthError) as caught:
|
||||
auth.login(self.base, "admin", "wrong")
|
||||
self.assertIn("不正确", str(caught.exception))
|
||||
|
||||
def test_login_requires_credentials(self):
|
||||
with self.assertRaises(auth.AuthError):
|
||||
auth.login(self.base, "", "admin123")
|
||||
with self.assertRaises(auth.AuthError):
|
||||
auth.login(self.base, "admin", "")
|
||||
|
||||
def test_login_rejects_invalid_base_url(self):
|
||||
with self.assertRaises(auth.AuthError):
|
||||
auth.login("ftp://nope", "admin", "admin123")
|
||||
|
||||
def test_login_invalid_response_body(self):
|
||||
_AuthApiHandler.mode["login"] = "badjson"
|
||||
with self.assertRaises(auth.AuthError) as caught:
|
||||
auth.login(self.base, "admin", "admin123")
|
||||
self.assertIn("响应无效", str(caught.exception))
|
||||
|
||||
def test_login_response_without_token(self):
|
||||
_AuthApiHandler.mode["login"] = "notoken"
|
||||
with self.assertRaises(auth.AuthError) as caught:
|
||||
auth.login(self.base, "admin", "admin123")
|
||||
self.assertIn("token", str(caught.exception))
|
||||
|
||||
def test_login_network_error(self):
|
||||
# 停掉服务器再请求:连接层失败 → 「无法连接」
|
||||
base = self.base
|
||||
self.server.shutdown()
|
||||
with self.assertRaises(auth.AuthError) as caught:
|
||||
auth.login(base, "admin", "admin123")
|
||||
self.assertIn("无法连接服务器", str(caught.exception))
|
||||
|
||||
def test_verify_token_roundtrip(self):
|
||||
token = auth.login(self.base, "admin", "admin123")["token"]
|
||||
result = auth.verify_token(self.base, token)
|
||||
self.assertEqual(result["username"], "admin")
|
||||
self.assertEqual(result["base_url"], self.base)
|
||||
self.assertEqual(result["login_time"], "2026-09-15T10:00:00")
|
||||
|
||||
def test_verify_token_rejects_garbage(self):
|
||||
with self.assertRaises(auth.AuthError):
|
||||
auth.verify_token(self.base, "not-a-token")
|
||||
with self.assertRaises(auth.AuthError) as caught:
|
||||
auth.verify_token(self.base, " ")
|
||||
self.assertIn("凭据", str(caught.exception))
|
||||
|
||||
def test_logout_invalidates_token(self):
|
||||
token = auth.login(self.base, "admin", "admin123")["token"]
|
||||
self.assertTrue(auth.verify_token(self.base, token))
|
||||
auth.logout(self.base, token)
|
||||
with self.assertRaises(auth.AuthError):
|
||||
auth.verify_token(self.base, token)
|
||||
|
||||
def test_logout_is_best_effort(self):
|
||||
# 服务器已停 / token 无效都不抛
|
||||
self.server.shutdown()
|
||||
auth.logout(self.base, "whatever")
|
||||
auth.logout(self.base, "")
|
||||
|
||||
|
||||
class SettingsAuthTest(VFSTestCase):
|
||||
"""settings 新增键的缺省与持久化。"""
|
||||
|
||||
def test_defaults_include_auth_keys(self):
|
||||
base = client_paths.ensure_layout(self.path("data"))
|
||||
data = client_settings.load(base)
|
||||
self.assertEqual(data["auth_token"], "")
|
||||
self.assertEqual(data["auth_user"], "")
|
||||
|
||||
def test_auth_keys_roundtrip(self):
|
||||
base = client_paths.ensure_layout(self.path("data"))
|
||||
data = client_settings.load(base)
|
||||
data.update({"base_url": "http://10.0.0.5:9000",
|
||||
"auth_token": "tok", "auth_user": "admin"})
|
||||
client_settings.save(base, data)
|
||||
loaded = client_settings.load(base)
|
||||
self.assertEqual(loaded["auth_token"], "tok")
|
||||
self.assertEqual(loaded["auth_user"], "admin")
|
||||
|
||||
|
||||
class NetUtilTest(VFSTestCase):
|
||||
"""loopback 请求的代理绕过:系统代理黑掉 localhost 是"接口一直在
|
||||
请求中"的经典原因。"""
|
||||
|
||||
class _OkHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802
|
||||
body = b'{"ok": true}'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# 全局策略与 opener 缓存跨用例残留,每个用例复位
|
||||
self.addCleanup(netutil.configure, "", "")
|
||||
netutil.configure("", "")
|
||||
|
||||
def test_is_loopback(self):
|
||||
for url in ("http://localhost:8000/a", "http://127.0.0.1:8000/",
|
||||
"http://[::1]:8000/", "http://api.localhost/x",
|
||||
"HTTP://LOCALHOST/x"):
|
||||
self.assertTrue(netutil.is_loopback(url), url)
|
||||
for url in ("http://example.com/", "http://192.168.1.5:8000/",
|
||||
"not a url"):
|
||||
self.assertFalse(netutil.is_loopback(url), url)
|
||||
|
||||
def test_loopback_bypasses_system_proxy(self):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), self._OkHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(server.shutdown)
|
||||
self.addCleanup(server.server_close)
|
||||
base = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
|
||||
# 模拟系统代理指向一个死端口:默认 urlopen 会失败(走代理),
|
||||
# netutil 对 loopback 强制直连 → 成功
|
||||
old_http = os.environ.get("http_proxy")
|
||||
old_https = os.environ.get("https_proxy")
|
||||
os.environ["http_proxy"] = "http://127.0.0.1:9"
|
||||
os.environ["https_proxy"] = "http://127.0.0.1:9"
|
||||
try:
|
||||
# 代理环境变量已生效(默认 urllib 会把请求发往该代理)。
|
||||
# 注意:这里只查 getproxies(),绝不能真用默认 urlopen 发请求——
|
||||
# 那会把"含死代理的 opener"缓存进全局,殃及之后所有测试模块。
|
||||
proxies = urllib.request.getproxies()
|
||||
self.assertEqual(proxies.get("http"), "http://127.0.0.1:9")
|
||||
# netutil 对 loopback 强制直连 → 不受代理影响
|
||||
fresh = urllib.request.Request(base + "/ok")
|
||||
with netutil.urlopen(fresh, timeout=3) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
finally:
|
||||
if old_http is None:
|
||||
os.environ.pop("http_proxy", None)
|
||||
else:
|
||||
os.environ["http_proxy"] = old_http
|
||||
if old_https is None:
|
||||
os.environ.pop("https_proxy", None)
|
||||
else:
|
||||
os.environ["https_proxy"] = old_https
|
||||
|
||||
|
||||
class _FlakyHandler(BaseHTTPRequestHandler):
|
||||
"""前 ``fail_times`` 次请求回 503,之后回 200;记录收到的每个请求。
|
||||
|
||||
模块级而不是塞在测试类里:嵌套类的方法体里看不到外层类作用域,
|
||||
引用它得写全限定名,很别扭。
|
||||
"""
|
||||
|
||||
fail_times = 0
|
||||
hits = []
|
||||
|
||||
def _handle(self):
|
||||
# 先把请求体读完再应答:不读就回错误码会让客户端在写 body 时
|
||||
# 撞上连接被重置(表现为 ConnectionResetError 而不是 HTTPError)
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
_FlakyHandler.hits.append((self.command, self.path))
|
||||
if len(_FlakyHandler.hits) <= _FlakyHandler.fail_times:
|
||||
self.send_response(503)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
body = b'{"ok": true}'
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
do_GET = _handle
|
||||
do_HEAD = _handle
|
||||
do_POST = _handle
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class NetPolicyTest(VFSTestCase):
|
||||
"""代理/CA 配置、幂等重试与设置落点(P1-5 / P1-7)。"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.addCleanup(netutil.configure, "", "")
|
||||
netutil.configure("", "")
|
||||
_FlakyHandler.hits = []
|
||||
_FlakyHandler.fail_times = 0
|
||||
|
||||
def _serve(self, handler=None):
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), handler or _FlakyHandler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
self.addCleanup(server.shutdown)
|
||||
self.addCleanup(server.server_close)
|
||||
return server, f"http://127.0.0.1:{server.server_address[1]}"
|
||||
|
||||
@staticmethod
|
||||
def _noop_sleep(_seconds):
|
||||
"""不真等退避时间(真实退避从 0.3s 起,两次重试就该等 0.9s)。"""
|
||||
|
||||
# ---- 重试策略 ---------------------------------------------------
|
||||
|
||||
def test_idempotent_get_retries_until_success(self):
|
||||
import urllib.request
|
||||
|
||||
_server, base = self._serve()
|
||||
_FlakyHandler.fail_times = 2 # 503、503、200
|
||||
request = urllib.request.Request(base + "/api/x")
|
||||
with netutil.open_with_retry(request, timeout=5, retries=2,
|
||||
sleep=self._noop_sleep) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(len(_FlakyHandler.hits), 3)
|
||||
|
||||
def test_post_is_never_retried(self):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
_server, base = self._serve()
|
||||
_FlakyHandler.fail_times = 99
|
||||
request = urllib.request.Request(base + "/api/order", data=b"{}",
|
||||
method="POST")
|
||||
with self.assertRaises(urllib.error.HTTPError) as caught:
|
||||
netutil.open_with_retry(request, timeout=5, retries=3,
|
||||
sleep=self._noop_sleep)
|
||||
self.assertEqual(caught.exception.code, 503)
|
||||
# 自动重发 POST 可能把"下单"变成两单:只允许发一次
|
||||
self.assertEqual(len(_FlakyHandler.hits), 1)
|
||||
|
||||
def test_retries_exhausted_raises_last_error(self):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
_server, base = self._serve()
|
||||
_FlakyHandler.fail_times = 99
|
||||
request = urllib.request.Request(base + "/api/x")
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
netutil.open_with_retry(request, timeout=5, retries=2,
|
||||
sleep=self._noop_sleep)
|
||||
self.assertEqual(len(_FlakyHandler.hits), 3) # 1 次 + 2 次重试
|
||||
|
||||
def test_non_retryable_status_is_not_retried(self):
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
class _NotFound(BaseHTTPRequestHandler):
|
||||
hits = 0
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
_NotFound.hits += 1
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
_server, base = self._serve(_NotFound)
|
||||
request = urllib.request.Request(base + "/api/x")
|
||||
with self.assertRaises(urllib.error.HTTPError):
|
||||
netutil.open_with_retry(request, timeout=5, retries=3,
|
||||
sleep=self._noop_sleep)
|
||||
self.assertEqual(_NotFound.hits, 1) # 404 重试没有意义
|
||||
|
||||
def test_transport_error_is_retried(self):
|
||||
"""连接层失败(拒绝连接/DNS/超时)也重试——服务器刚起来那几秒。"""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
attempts = []
|
||||
|
||||
class _Response:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def flaky(request, *, timeout):
|
||||
attempts.append(1)
|
||||
if len(attempts) < 3:
|
||||
raise urllib.error.URLError("connection refused")
|
||||
return _Response()
|
||||
|
||||
original = netutil.urlopen
|
||||
netutil.urlopen = flaky
|
||||
self.addCleanup(setattr, netutil, "urlopen", original)
|
||||
request = urllib.request.Request("http://127.0.0.1:1/api/x")
|
||||
with netutil.open_with_retry(request, timeout=5, retries=2,
|
||||
sleep=self._noop_sleep) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(len(attempts), 3)
|
||||
|
||||
# ---- 代理与 CA ---------------------------------------------------
|
||||
|
||||
def test_explicit_proxy_for_remote_and_loopback_stays_direct(self):
|
||||
netutil.configure("http://proxy.corp:3128")
|
||||
remote = {type(h).__name__: h for h in netutil._opener(False).handlers}
|
||||
self.assertEqual(
|
||||
remote["ProxyHandler"].proxies,
|
||||
{"http": "http://proxy.corp:3128",
|
||||
"https": "http://proxy.corp:3128"},
|
||||
)
|
||||
# loopback 即使配了代理也直连:opener 里干脆没有代理handler
|
||||
# (ProxyHandler({}) 不生成 *_open 方法,build_opener 会丢掉它)
|
||||
local = {type(h).__name__ for h in netutil._opener(True).handlers}
|
||||
self.assertNotIn("ProxyHandler", local)
|
||||
|
||||
def test_missing_ca_file_is_reported_and_not_applied(self):
|
||||
error = netutil.configure("", self.path("nope.pem"))
|
||||
self.assertIsNotNone(error)
|
||||
self.assertIn("不存在", error)
|
||||
self.assertEqual(netutil.current(), ("", "")) # 没生效(不假装配上了)
|
||||
|
||||
def test_ca_bundle_valid_pem_installs_https_handler(self):
|
||||
import ssl
|
||||
|
||||
source = ssl.get_default_verify_paths().cafile
|
||||
if not source or not os.path.isfile(source):
|
||||
self.skipTest("本机没有可复制的默认 CA 文件(Windows 无 cafile)")
|
||||
bundle = self.path("ca.pem")
|
||||
with open(source, "rb") as src, open(bundle, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
self.assertIsNone(netutil.configure("", bundle))
|
||||
handlers = netutil._opener(False).handlers
|
||||
self.assertTrue(any(type(h).__name__ == "HTTPSHandler" for h in handlers))
|
||||
|
||||
def test_bad_ca_content_degrades_without_crashing(self):
|
||||
"""CA 文件在但内容是垃圾:退回系统信任库,不能把客户端搞挂。"""
|
||||
from svfs_client import diagnostics
|
||||
|
||||
bundle = self.path("junk.pem")
|
||||
with open(bundle, "w", encoding="utf-8") as handle:
|
||||
handle.write("这不是一个 PEM 证书\n")
|
||||
self.assertIsNone(netutil.configure("", bundle)) # 文件存在 → 配置接受
|
||||
netutil._opener(False) # 构建 opener 不抛(退回系统信任库)
|
||||
self.assertIn("CA 证书无法加载", "\n".join(diagnostics.recent()))
|
||||
|
||||
def test_apply_settings_reads_settings_json(self):
|
||||
base = client_paths.ensure_layout(self.path("data"))
|
||||
data = client_settings.load(base)
|
||||
data["proxy"] = "10.0.0.9:8080" # 只写 host:port 也认
|
||||
client_settings.save(base, data)
|
||||
self.assertIsNone(netutil.apply_settings(base))
|
||||
self.assertEqual(netutil.current()[0], "http://10.0.0.9:8080")
|
||||
|
||||
def test_settings_net_options_fall_back_on_bad_values(self):
|
||||
base = client_paths.ensure_layout(self.path("data"))
|
||||
self.assertEqual(client_settings.net_options(base),
|
||||
(client_settings.NET_TIMEOUT_DEFAULT,
|
||||
client_settings.NET_RETRIES_DEFAULT))
|
||||
data = client_settings.load(base)
|
||||
data.update({"net_timeout": "很快", "net_retries": -3})
|
||||
client_settings.save(base, data)
|
||||
# 坏配置不该让网络不可用:一律落回默认值
|
||||
self.assertEqual(client_settings.net_options(base),
|
||||
(client_settings.NET_TIMEOUT_DEFAULT,
|
||||
client_settings.NET_RETRIES_DEFAULT))
|
||||
data.update({"net_timeout": 0, "net_retries": 0})
|
||||
client_settings.save(base, data)
|
||||
# 超时 0 没意义(会把工作线程钉死)→ 用默认;重试 0 = 明确不重试
|
||||
self.assertEqual(client_settings.net_options(base),
|
||||
(client_settings.NET_TIMEOUT_DEFAULT, 0))
|
||||
|
||||
def test_bridge_reads_net_options_from_provider(self):
|
||||
"""桥现读设置(provider 抛错时退回默认值,坏回调不该让桥瘫痪)。"""
|
||||
from svfs_client.bridge import NET_TIMEOUT, SvfsBridge
|
||||
|
||||
bridge = SvfsBridge("com.example.demo", ["net"],
|
||||
self.path("data/storage.json"),
|
||||
base_url_provider=lambda: "http://127.0.0.1:1",
|
||||
net_options_provider=lambda: (3, 2))
|
||||
self.addCleanup(bridge.shutdown)
|
||||
self.assertEqual(bridge._net_options(), (3, 2))
|
||||
bridge._net_options_provider = lambda: 1 / 0
|
||||
self.assertEqual(bridge._net_options(), (NET_TIMEOUT, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user