Files
HeXi/tests/test_mc_servers.py
T
sansenhoshiandClaude Code 814c1994b6 feat(mc-server-status): 服务器列表 Web 管理页 / 数据层抽离 / mcstatus 13 适配
- services/servers.py:校验 + 读写 + 增删改收敛成一份数据层(顶层不 import nonebot,
  可脱离运行时单测),群命令与 Web API 都只经它改数据,校验不再各写一套
- web_hub.py + /hub/mc_server_status 页面:按群列出服务器,抽屉新增/编辑(改名)/
  测试连接,行内删除(删空一台连带删群);每条路由自带 require_admin
- handlers/server.py 改为薄封装,只在需要命令特有提示时自己判断
- services/mc.py 的图标属性名兼容 mcstatus ≥13 的 icon(旧版叫 favicon,
  读错会把整条查询打成"查询失败")
- tests/test_mc_servers.py:数据层归一/改名/删群/坏文件兜底 + 图标解码两版属性名

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:46 +08:00

341 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MC 服务器数据层 (services/servers.py) 单元测试
servers.py 顶层不 import nonebot(运行态 config 是运行时才取),但 config.py
里有 `from .services import ...` 这类相对导入,所以这里**按包结构**用 importlib
加载(而不是像 test_video_policy.py 那样裸加载单文件):造一棵一次性的包骨架
塞进 sys.modules,真正的插件包 `nonebot_plugin_mc_server_status` 全程没被
NoneBot 加载过。
**每个用例重建一份 servers/config 模块**:增删改落在 `config.var.group_list`
上,直接复用进程里已有的 config 会把正在跑的 bot 的内存态改写掉(测试污染运行
数据),所以每次都用全新的模块对象 + tmp_path 下的数据文件。
"""
import base64
import importlib.util
import json
import sys
import types
from pathlib import Path
import pytest
_PLUGIN_DIR = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_mc_server_status"
)
pytestmark = pytest.mark.skipif(
not (_PLUGIN_DIR / "config.py").exists(), reason="MC 插件不存在"
)
_PKG = "mc_status_testpkg"
_SERVERS = f"{_PKG}.services.servers"
_CONFIG = f"{_PKG}.config"
def _load_by_path(name: str, path: Path):
spec = importlib.util.spec_from_file_location(
name, path, submodule_search_locations=[] if path.is_dir() else None
)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
class _StubDriver:
"""config.py 的 @driver.on_startup / on_bot_connect 只登记回调,测试里不触发。"""
def __getattr__(self, _name):
def _decorator(*_args, **_kwargs):
return lambda func: func
return _decorator
def _init_nonebot_stubs():
"""config.py 顶层要 get_driver()/get_plugin_config()/nonebot.log,没有运行时就得打桩。
先真 `import nonebot` 再把这几个入口换掉(而不是塞一个假包进 sys.modules):
假包会让 python 用它的 `__path__` 去找 `nonebot.adapters`,反而找不到真子模块。
"""
import nonebot
pkg = types.ModuleType("nonebot.log")
pkg.logger = types.SimpleNamespace( # type: ignore[attr-defined]
debug=lambda *a, **k: None,
info=lambda *a, **k: None,
warning=lambda *a, **k: None,
error=lambda *a, **k: None,
)
saved = {
"get_driver": nonebot.get_driver, # type: ignore[attr-defined]
"get_plugin_config": nonebot.get_plugin_config, # type: ignore[attr-defined]
"get_bot": nonebot.get_bot, # type: ignore[attr-defined]
"get_bots": nonebot.get_bots, # type: ignore[attr-defined]
"log": sys.modules.get("nonebot.log"),
}
nonebot.get_driver = lambda: _StubDriver() # type: ignore[attr-defined]
nonebot.get_plugin_config = lambda *_a, **_k: types.SimpleNamespace( # type: ignore[attr-defined]
mc_status_data_filename="mc_status_data.json"
)
nonebot.get_bot = lambda *_a, **_k: None # type: ignore[attr-defined]
nonebot.get_bots = lambda: {} # type: ignore[attr-defined]
sys.modules["nonebot.log"] = pkg
def restore() -> None:
nonebot.get_driver = saved["get_driver"] # type: ignore[attr-defined]
nonebot.get_plugin_config = saved["get_plugin_config"] # type: ignore[attr-defined]
nonebot.get_bot = saved["get_bot"] # type: ignore[attr-defined]
nonebot.get_bots = saved["get_bots"] # type: ignore[attr-defined]
if saved["log"] is None:
sys.modules.pop("nonebot.log", None)
else:
sys.modules["nonebot.log"] = saved["log"]
return restore
@pytest.fixture
def mcs(tmp_path):
"""一套干净的数据层:一次性包骨架 + 全新 servers/config 模块 + 临时数据文件。"""
pkg = types.ModuleType(_PKG)
pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined]
sys.modules[_PKG] = pkg
services = types.ModuleType(f"{_PKG}.services")
services.__path__ = [str(_PLUGIN_DIR / "services")] # type: ignore[attr-defined]
sys.modules[f"{_PKG}.services"] = services
restore = _init_nonebot_stubs()
try:
module = _load_by_path(_SERVERS, _PLUGIN_DIR / "services" / "servers.py")
runtime = _load_by_path(_CONFIG, _PLUGIN_DIR / "config.py")
finally:
restore()
# config.py 的 on_startup 不在测试里跑,内存态与数据文件名手动准备
runtime.var.group_list.clear()
runtime.pc.mc_status_data_filename = str(tmp_path / "mc_status_data.json")
try:
yield module
finally:
for name in (_SERVERS, _CONFIG, f"{_PKG}.services", _PKG):
sys.modules.pop(name, None)
@pytest.fixture
def groups(mcs):
return mcs._runtime().var.group_list
@pytest.fixture
def mc_module():
"""加载 services/mc.py(只测纯函数 _server_icon,不联网)。
mc.py 在模块顶层 import 了 OneBot 的消息类型,这里给个占位桩 —— 测试只关心
图标解码,不会真的构造 Message。
"""
pkg = types.ModuleType(_PKG)
pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined]
sys.modules[_PKG] = pkg
services = types.ModuleType(f"{_PKG}.services")
services.__path__ = [str(_PLUGIN_DIR / "services")] # type: ignore[attr-defined]
sys.modules[f"{_PKG}.services"] = services
adapter = types.ModuleType("nonebot.adapters.onebot.v11")
adapter.Message = type("Message", (), {})
adapter.MessageSegment = types.SimpleNamespace(image=lambda *_a, **_k: object())
saved = sys.modules.get("nonebot.adapters.onebot.v11")
sys.modules["nonebot.adapters.onebot.v11"] = adapter
try:
module = _load_by_path(
f"{_PKG}.services.mc", _PLUGIN_DIR / "services" / "mc.py"
)
finally:
if saved is None:
sys.modules.pop("nonebot.adapters.onebot.v11", None)
else:
sys.modules["nonebot.adapters.onebot.v11"] = saved
try:
yield module
finally:
for name in (f"{_PKG}.services.mc", f"{_PKG}.services", _PKG):
sys.modules.pop(name, None)
# ───────────────────────── 服务器图标(mcstatus 跨版本) ─────────────────────────
_PNG = b"\x89PNG\r\n\x1a\n" + b"fake-image-bytes"
def test_server_icon_accepts_new_and_old_attribute_names(mc_module):
"""mcstatus ≥13 是 icon,11/12 是 favicon —— 两种都要认(线上 13.0.1 踩过一次)。"""
b64 = base64.b64encode(_PNG).decode()
new_status = types.SimpleNamespace(icon=f"data:image/png;base64,{b64}")
old_status = types.SimpleNamespace(favicon=f"data:image/png;base64,{b64}")
assert mc_module._server_icon(new_status) == _PNG
assert mc_module._server_icon(old_status) == _PNG
def test_server_icon_handles_bare_base64_and_missing(mc_module):
b64 = base64.b64encode(_PNG).decode()
assert mc_module._server_icon(types.SimpleNamespace(icon=b64)) == _PNG # 无 data URI 前缀
assert mc_module._server_icon(types.SimpleNamespace(icon=None)) is None
assert mc_module._server_icon(types.SimpleNamespace()) is None # 两个属性都没有
assert mc_module._server_icon(types.SimpleNamespace(icon="不是 base64!!")) is None
def _file(mcs) -> dict:
return json.loads(Path(mcs._runtime().pc.mc_status_data_filename).read_text(encoding="utf-8"))
# ───────────────────────── 校验 / 归一化 ─────────────────────────
def test_normalize_type_aliases_and_errors(mcs):
assert mcs.normalize_type("js") == "js"
assert mcs.normalize_type("JS") == "js"
assert mcs.normalize_type(" Java ") == "js"
assert mcs.normalize_type("bds") == "bds"
assert mcs.normalize_type("bedrock") == "bds"
assert mcs.normalize_type("") == "js" # 空值取默认
assert mcs.normalize_type(None, "bds") == "bds"
with pytest.raises(mcs.ServerError):
mcs.normalize_type("java版")
def test_normalize_address_strips_scheme_and_rejects_junk(mcs):
assert mcs.normalize_address(" mc.example.com:25565 ") == "mc.example.com:25565"
assert mcs.normalize_address("minecraft://mc.example.com") == "mc.example.com"
assert mcs.normalize_address("http://mc.example.com/") == "mc.example.com"
for bad in ("", " ", "mc example.com", "mc.example.com/foo", ":25565", "host:"):
with pytest.raises(mcs.ServerError):
mcs.normalize_address(bad)
def test_normalize_name_keeps_spaces_and_strips_quotes(mcs):
assert mcs.normalize_name("亡者世界") == "亡者世界"
assert mcs.normalize_name(" 我 的 服 ") == "我 的 服"
assert mcs.normalize_name('"EMDS"') == "EMDS"
with pytest.raises(mcs.ServerError):
mcs.normalize_name(" ")
with pytest.raises(mcs.ServerError):
mcs.normalize_name("x" * (mcs.MAX_NAME_LEN + 2))
def test_parse_group_id(mcs):
assert mcs.parse_group_id("872490448") == 872490448
assert mcs.parse_group_id(304484502) == 304484502
for bad in ("", " ", "abc", "872490448.0", "0", 1234567890123):
with pytest.raises(mcs.ServerError):
mcs.parse_group_id(bad)
# ───────────────────────── 增 / 改 ─────────────────────────
def test_add_writes_file_and_memory(mcs, groups):
assert mcs.set_server(872490448, "EMDS", "sansenhoshi.top:29985", "js") is True
assert groups[872490448]["EMDS"] == ["sansenhoshi.top:29985", "js"]
assert _file(mcs) == {"872490448": {"EMDS": ["sansenhoshi.top:29985", "js"]}}
def test_update_same_name_keeps_position(mcs, groups):
mcs.set_server(872490448, "EMDS", "a.example.com:1", "js")
mcs.set_server(872490448, "星门", "b.example.com:2", "bds")
assert mcs.set_server(872490448, "EMDS", "c.example.com:3", "js") is False # 同名 = 更新
assert list(groups[872490448]) == ["EMDS", "星门"] # 顺序不变
assert groups[872490448]["EMDS"] == ["c.example.com:3", "js"]
def test_rename_moves_entry(mcs, groups):
mcs.set_server(872490448, "旧名", "mc.example.com:25565", "js")
assert mcs.set_server(872490448, "新名", "mc.example.com:25565", "js", old_name="旧名") is True
assert list(groups[872490448]) == ["新名"]
assert _file(mcs)["872490448"]["新名"] == ["mc.example.com:25565", "js"]
def test_rename_conflict_is_rejected_without_touching_data(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
mcs.set_server(872490448, "B", "b.example.com:2", "js")
with pytest.raises(mcs.ServerError):
mcs.set_server(872490448, "B", "c.example.com:3", "js", old_name="A")
assert groups[872490448] == {"A": ["a.example.com:1", "js"], "B": ["b.example.com:2", "js"]}
def test_rename_missing_source_is_rejected(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
with pytest.raises(mcs.ServerError):
mcs.set_server(872490448, "B", "b.example.com:2", "js", old_name="不存在")
assert list(groups[872490448]) == ["A"]
def test_invalid_input_does_not_write_file(mcs, groups, tmp_path):
with pytest.raises(mcs.ServerError):
mcs.set_server(872490448, "A", "a.example.com:1", "不是类型")
assert groups == {}
assert not (tmp_path / "mc_status_data.json").exists()
# ───────────────────────── 删 ─────────────────────────
def test_delete_server_and_cleanup_empty_group(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
mcs.set_server(872490448, "B", "b.example.com:2", "js")
mcs.set_server(304484502, "C", "c.example.com:3", "bds")
mcs.delete_server(872490448, "A")
assert list(groups[872490448]) == ["B"]
mcs.delete_server(872490448, "B")
assert 872490448 not in groups # 删空连带删群
assert _file(mcs) == {"304484502": {"C": ["c.example.com:3", "bds"]}}
def test_delete_unknown_targets(mcs, groups):
with pytest.raises(mcs.ServerError):
mcs.delete_server(872490448, "没有这个群")
mcs.set_server(872490448, "A", "a.example.com:1", "js")
with pytest.raises(mcs.ServerError):
mcs.delete_server(872490448, "没有这台")
def test_delete_group(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
mcs.delete_group(872490448)
assert groups == {}
with pytest.raises(mcs.ServerError):
mcs.delete_group(872490448)
# ───────────────────────── 读 ─────────────────────────
def test_load_servers_handles_missing_broken_and_partial(mcs, tmp_path):
path = tmp_path / "mc_status_data.json"
assert mcs.load_servers(str(path)) == {} # 文件不存在
path.write_text("{ 坏掉的 json", encoding="utf-8")
assert mcs.load_servers(str(path)) == {} # 坏文件不炸
path.write_text(json.dumps({"8123": {"A": ["a:1", "js"], "B": "不是数组"}}), encoding="utf-8")
assert mcs.load_servers(str(path)) == {8123: {"A": ["a:1", "js"]}}
def test_snapshot_is_a_copy(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
snap = mcs.snapshot()
snap[872490448]["A"][0] = "改坏了"
snap[872490448]["新"] = ["x:1", "js"]
assert groups[872490448] == {"A": ["a.example.com:1", "js"]}
def test_lookup(mcs, groups):
mcs.set_server(872490448, "A", "a.example.com:1", "js")
assert mcs.lookup(872490448, "A") == ["a.example.com:1", "js"]
assert mcs.lookup(872490448, "没有这台") is None
assert mcs.lookup(999, "A") is None