"""出站媒体内联化单元测试 hexi/core 包 __init__ 会触发 NoneBot 运行时初始化(get_driver), 无法裸导入,故用 importlib 以真实模块名加载 outbound_media 并注册进 sys.modules(game: 与 test_rate_limit / test_cooldown 同一套路)。 文件一律用 tmp_path 造真实文件,**不 mock 文件系统**——这类转换的坑 恰恰在真实路径语义上(file:// 的百分号编码、Windows 盘符、相对路径), mock 掉就等于把要验的东西验没了。 """ import importlib.util import sys from pathlib import Path import pytest from nonebot.adapters.onebot.v11 import Message, MessageSegment _BASE = Path(__file__).resolve().parents[1] / "hexi" / "core" _MOD_NAME = "hexi.core.outbound_media" def _load_module(name: str, path: Path): spec = importlib.util.spec_from_file_location(name, path) module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) return module @pytest.fixture(scope="module") def om(): module = _load_module(_MOD_NAME, _BASE / "outbound_media.py") yield module sys.modules.pop(_MOD_NAME, None) @pytest.fixture def media_file(tmp_path: Path) -> Path: """带空格与中文的真实文件:同时覆盖 URL 编码与多字节路径两种坑。""" fp = tmp_path / "有 空格 的文件.jpg" fp.write_bytes(b"hello") return fp class _FakeAdapter: def __init__(self, name: str = "OneBot V11"): self._name = name def get_name(self) -> str: return self._name class _FakeBot: def __init__(self, name: str = "OneBot V11"): self.adapter = _FakeAdapter(name) # ------------------------------------------------------------ local_file_of def test_file_uri_with_spaces_and_unicode(om, media_file: Path): """file:// URI(带 %20 与中文百分号编码)必须能还原成本机真实路径。 这是整个模块最容易静默失效的一处:天真的 `Path(unquote(urlparse(uri).path))` 在 Windows 上会得到前导斜杠的 `/D:/...`,is_file() 恒为 False 而**不报错**,只在跨机时才暴露。 """ uri = media_file.as_uri() assert "%" in uri # 确认确实带百分号编码,否则这条测试没验到东西 assert om.local_file_of(uri) == media_file def test_plain_absolute_path(om, media_file: Path): """f2s() 对 str 是原样透传的,所以裸路径也得认。""" assert om.local_file_of(str(media_file)) == media_file def test_relative_path(om, media_file: Path, monkeypatch): monkeypatch.chdir(media_file.parent) assert om.local_file_of(media_file.name) == Path(media_file.name) @pytest.mark.parametrize( "value", [ "http://example.com/a.jpg", "https://example.com/a.jpg", "base64://aGVsbG8=", ], ) def test_remote_and_inline_values_pass_through(om, value: str): assert om.local_file_of(value) is None @pytest.mark.parametrize( "value", [ "D:/nope/does_not_exist.jpg", "file:///D:/nope/does_not_exist.jpg", # 协议端回传的图片 id / 它自己的缓存路径:不是本机文件,必须自然放行 "{12345678-1234-1234-1234-123456789abc}.jpg", "C:/NapCat/cache/abcdef.image", "", ], ) def test_non_local_values_are_left_alone(om, value: str): assert om.local_file_of(value) is None @pytest.mark.parametrize("value", [None, 123, b"bytes", ["a.jpg"]]) def test_non_string_values_are_left_alone(om, value): assert om.local_file_of(value) is None # ------------------------------------------------------------ 改写目标 async def test_falls_back_to_base64_when_no_url(om, media_file: Path, monkeypatch): """拿不到可达地址(未挂载)→ base64 兜底,字节由 bot 自己携带。""" monkeypatch.setattr(om, "_mounted", False) assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8=" async def test_url_mode_when_mounted(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", True) monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697/") url = await om.rewrite_file(str(media_file)) assert url.startswith("http://192.168.2.15:39697/media/") # 基地址结尾斜杠不该被拼成 //media assert "//media" not in url token = url.rsplit("/", 1)[1] assert om._tokens[token][0] == media_file async def test_url_mode_can_be_disabled(om, media_file: Path, monkeypatch): """URL 模式关掉后一律 base64,即使媒体服务挂着。""" monkeypatch.setattr(om, "_mounted", True) monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697") monkeypatch.setenv("HEXI_INLINE_MEDIA_URL", "false") assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8=" async def test_rewrite_is_idempotent(om, media_file: Path, monkeypatch): """重复执行必须无副作用:第二次看到 base64:// 应直接放行。""" monkeypatch.setattr(om, "_mounted", False) once = await om.rewrite_file(str(media_file)) assert await om.rewrite_file(once) is None async def test_rewrite_returns_none_for_remote(om): assert await om.rewrite_file("http://example.com/a.jpg") is None # ------------------------------------------------------------ 遍历形态 async def test_walk_rewrites_message_segments(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", False) msg = Message( [ MessageSegment.text("看图"), MessageSegment.image(media_file), MessageSegment.face(1), ] ) await om._walk(msg) assert msg[0].data["text"] == "看图" assert msg[1].data["file"] == "base64://aGVsbG8=" assert msg[2].type == "face" # 非媒体段不动 @pytest.mark.parametrize("seg_type", ["image", "record", "video"]) async def test_walk_rewrites_all_media_types( om, media_file: Path, monkeypatch, seg_type ): monkeypatch.setattr(om, "_mounted", False) seg = {"type": seg_type, "data": {"file": str(media_file)}} await om._walk(seg) assert seg["data"]["file"] == "base64://aGVsbG8=" async def test_walk_handles_bare_dict_segment(om, media_file: Path, monkeypatch): """group_daily_analysis 那种纯 dict 段(不是 Message 也不是 MessageSegment)。""" monkeypatch.setattr(om, "_mounted", False) payload = {"type": "image", "data": {"file": str(media_file)}} await om._walk(payload) assert payload["data"]["file"] == "base64://aGVsbG8=" async def test_walk_handles_list_of_dict_segments(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", False) payload = [ {"type": "text", "data": {"text": "hi"}}, {"type": "image", "data": {"file": str(media_file)}}, ] await om._walk(payload) assert payload[1]["data"]["file"] == "base64://aGVsbG8=" async def test_walk_forward_node_with_str_content(om, media_file: Path, monkeypatch): """message_utils.build_forward_nodes: content 是 str(不含媒体,应保持原样)。""" monkeypatch.setattr(om, "_mounted", False) nodes = [ {"type": "node", "data": {"name": "x", "uin": "1", "content": "纯文本"}}, ] await om._walk(nodes) assert nodes[0]["data"]["content"] == "纯文本" async def test_walk_forward_node_with_dict_list_content( om, media_file: Path, monkeypatch ): """video_analysis._build_forward_nodes: content 是段字典数组。""" monkeypatch.setattr(om, "_mounted", False) nodes = [ { "type": "node", "data": { "name": "x", "uin": "1", "content": [{"type": "video", "data": {"file": str(media_file)}}], }, } ] await om._walk(nodes) assert nodes[0]["data"]["content"][0]["data"]["file"] == "base64://aGVsbG8=" async def test_walk_forward_node_with_message_content( om, media_file: Path, monkeypatch ): """MessageSegment.node_custom 会把 Message 原样塞进 data["content"]。""" monkeypatch.setattr(om, "_mounted", False) node = MessageSegment.node_custom( 1, "n", Message([MessageSegment.image(media_file)]) ) await om._walk([node]) inner = node.data["content"][0] assert isinstance(inner, MessageSegment) assert inner.data["file"] == "base64://aGVsbG8=" async def test_walk_recurses_messages_key(om, media_file: Path, monkeypatch): """转发的 messages 还可能是 Message 对象(alconna 的 include() 返回同类型)。""" monkeypatch.setattr(om, "_mounted", False) data = { "messages": Message( [ MessageSegment.node_custom( 1, "n", Message([MessageSegment.image(media_file)]) ) ] ) } await om._walk(data["messages"]) assert data["messages"][0].data["content"][0].data["file"] == "base64://aGVsbG8=" async def test_walk_leaves_remote_media_alone(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", False) seg = {"type": "image", "data": {"file": "https://example.com/a.jpg"}} await om._walk(seg) assert seg["data"]["file"] == "https://example.com/a.jpg" async def test_walk_respects_depth_limit(om, media_file: Path, monkeypatch): """超深嵌套不再下钻:拦截层不该在畸形结构上无界递归。""" monkeypatch.setattr(om, "_mounted", False) node: dict = {"type": "image", "data": {"file": str(media_file)}} for _ in range(20): node = {"type": "node", "data": {"content": [node]}} await om._walk(node) deepest = node while isinstance(deepest, dict) and "content" in deepest.get("data", {}): deepest = deepest["data"]["content"][0] assert deepest["data"]["file"] == str(media_file) # 没被改写 # ------------------------------------------------------------ 钩子 async def test_hook_rewrites_send_api(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", False) data = {"group_id": 1, "message": Message([MessageSegment.image(media_file)])} await om._inline_outbound_media(_FakeBot(), "send_group_msg", data) assert data["message"][0].data["file"] == "base64://aGVsbG8=" async def test_hook_ignores_unrelated_api(om, media_file: Path, monkeypatch): """既非发送也非文件上传的 API 一律不碰。""" monkeypatch.setattr(om, "_mounted", True) monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697") data = {"group_id": 1, "file": str(media_file)} await om._inline_outbound_media(_FakeBot(), "get_group_info", data) assert data["file"] == str(media_file) async def test_hook_ignores_other_adapters(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", False) data = {"message": Message([MessageSegment.image(media_file)])} before = data["message"][0].data["file"] await om._inline_outbound_media(_FakeBot("Telegram"), "send_group_msg", data) assert data["message"][0].data["file"] == before async def test_hook_disabled_by_env(om, media_file: Path, monkeypatch): """关掉开关 = 声明「协议端就在本机」,此时原样发送才是对的。""" monkeypatch.setenv("HEXI_INLINE_MEDIA", "false") data = {"message": Message([MessageSegment.image(media_file)])} before = data["message"][0].data["file"] await om._inline_outbound_media(_FakeBot(), "send_group_msg", data) assert data["message"][0].data["file"] == before # f2s() 对 Path 产出 file:// URI,正是本模块要拦的那种值 assert before.startswith("file://") async def test_hook_handles_forward_messages_key(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", False) data = { "group_id": 1, "messages": [ { "type": "node", "data": { "name": "n", "uin": "1", "content": [{"type": "image", "data": {"file": str(media_file)}}], }, } ], } await om._inline_outbound_media(_FakeBot(), "send_group_forward_msg", data) seg = data["messages"][0]["data"]["content"][0] assert seg["data"]["file"] == "base64://aGVsbG8=" async def test_hook_never_raises_on_broken_segment(om, monkeypatch): """拦截层绝不能反过来把发送搞挂:畸形段只告警。""" monkeypatch.setattr(om, "_mounted", False) data = {"message": [{"type": "image", "data": {"file": object()}}]} await om._inline_outbound_media(_FakeBot(), "send_group_msg", data) # ------------------------------------------------------------ 文件上传 def _unescaped_uri(path: Path) -> str: """video_analysis._file_uri 的手工拼法:正斜杠、不转义。""" return "file:///" + str(path.resolve()).replace("\\", "/") async def test_upload_file_url_when_mounted(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", True) monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697") data = {"group_id": 1, "file": str(media_file), "name": "显示名.zip"} await om._inline_outbound_media(_FakeBot(), "upload_group_file", data) assert data["file"].startswith("http://192.168.2.186:39697/media/") assert data["name"] == "显示名.zip" # 其它参数一律不碰 assert om._tokens[data["file"].rsplit("/", 1)[1]][0] == media_file @pytest.mark.parametrize( "builder", [ str, # 裸路径(alconna $onebot11:file 的 as_posix() 形态) lambda p: p.as_uri(), # Path.as_uri():带百分号转义 _unescaped_uri, # 手工拼:不转义 ], ids=["plain", "as_uri", "unescaped"], ) async def test_upload_file_accepts_all_uri_forms( om, media_file: Path, monkeypatch, builder ): monkeypatch.setattr(om, "_mounted", True) monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697") data = {"file": builder(media_file)} await om._inline_outbound_media(_FakeBot(), "upload_private_file", data) assert data["file"].startswith("http://192.168.2.186:39697/media/") async def test_upload_file_has_no_base64_fallback(om, media_file: Path, monkeypatch): """文件上传不做 base64 兜底:没 URL 就原样放行(不能比改动前更糟)。""" monkeypatch.setattr(om, "_mounted", False) data = {"group_id": 1, "file": str(media_file)} await om._inline_outbound_media(_FakeBot(), "upload_group_file", data) assert data["file"] == str(media_file) async def test_upload_file_respects_url_toggle(om, media_file: Path, monkeypatch): monkeypatch.setattr(om, "_mounted", True) monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697") monkeypatch.setenv("HEXI_INLINE_MEDIA_URL", "false") data = {"group_id": 1, "file": str(media_file)} await om._inline_outbound_media(_FakeBot(), "upload_group_file", data) assert data["file"] == str(media_file) async def test_upload_file_ignores_s3_url(om, media_file: Path, monkeypatch): """S3 预签名链接是群文件的首选路径,不能被碰。""" monkeypatch.setattr(om, "_mounted", True) monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.186:39697") url = "http://192.168.2.15:5246/PLANA/x.zip?X-Amz-Signature=abc" data = {"group_id": 1, "file": url} await om._inline_outbound_media(_FakeBot(), "upload_group_file", data) assert data["file"] == url async def test_local_file_of_handles_literal_percent(tmp_path: Path, om): """文件名含字面 %XX 时,未转义的手拼 URI 不能被 unquote 带偏。 先按解码结果找(会解析成错误路径),再退回原始路径 —— 这条兜底的意义就在此。 """ fp = tmp_path / "100%20off.zip" fp.write_bytes(b"zip") assert om.local_file_of(_unescaped_uri(fp)) == fp async def test_media_endpoint_roundtrip(om, media_file: Path, monkeypatch): """token → 路径 的登记与过期语义(不依赖 FastAPI,直接验注册表)。""" monkeypatch.setattr(om, "_mounted", True) monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://127.0.0.1:11011") monkeypatch.setenv("HEXI_MEDIA_URL_TTL", "60") url = await om.rewrite_file(str(media_file)) token = url.rsplit("/", 1)[1] path, expires = om._tokens[token] assert path == media_file assert expires > om.time.monotonic() # token 不可猜:长度足够且不含路径信息 assert len(token) >= 20 assert "空格" not in token