Files
HeXi/tests/test_outbound_media.py
T
sansenhoshiandClaude Code 0ac1958932 feat(core): 定时任务薄封装 scheduler / 跨插件任务槽 task_slots / 出站媒体显式发送方式
- scheduler.py:apscheduler 薄封装(统一 Asia/Shanghai、misfire_grace_time、起止/异常日志)
- task_slots.py:长任务任务槽(进度上报 / 同槽去重 / SSE 推送 / 取消),
  video_analysis temp 清理等长任务接入
- outbound_media:新增 HEXI_MEDIA_MODE 显式发送方式(http/base64/file,/hub 可改),
  修复 alconna 对 base64 载荷补 https:// 前缀导致的 fetch failed(repair_mangled_url)
- learning_chat 定时任务迁入 hexi.core.scheduler
- 测试:新增 test_scheduler / test_task_slots,扩充 test_outbound_media

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:21 +08:00

646 lines
25 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.
"""出站媒体内联化单元测试
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
# ------------------------------------------------------------ 可达地址选择
def _fake_psutil(monkeypatch, ips_by_iface: dict[str, list[str]]) -> None:
"""把 psutil.net_if_addrs 换成假网卡表(`_local_ips` 在函数内 import psutil)。"""
import socket as _socket
import types
fake = types.ModuleType("psutil")
fake.net_if_addrs = lambda: {
name: [
types.SimpleNamespace(family=_socket.AF_INET, address=ip) for ip in ips
]
for name, ips in ips_by_iface.items()
}
monkeypatch.setitem(sys.modules, "psutil", fake)
def test_route_ip_wins_over_virtual_adapter(om, monkeypatch):
"""回归 2026-10-08:EasyTier 的 10.0.0.129 按字符串排序赢过真实局域网,
协议端拉不到媒体(ActionFailed fetch failed)。默认路由网卡必须排最前。"""
_fake_psutil(
monkeypatch,
{
"EasyTier": ["10.0.0.129"],
"以太网": ["192.168.2.186"],
"WLAN": ["169.254.65.98"], # 链路本地,应被过滤
},
)
monkeypatch.setattr(om, "_route_ip", lambda: "192.168.2.186")
ips = om._local_ips()
assert ips[0] == "192.168.2.186"
assert ips == ["192.168.2.186", "10.0.0.129"]
def test_local_ips_without_route_falls_back_to_private_first(om, monkeypatch):
"""探不到默认路由(无网络/异常)时退回原排序:私有段优先、其余按字符串。"""
_fake_psutil(
monkeypatch,
{"Ethernet": ["192.168.2.186"], "VPN": ["10.0.0.129"], "eth1": ["203.0.113.9"]},
)
monkeypatch.setattr(om, "_route_ip", lambda: None)
assert om._local_ips() == ["10.0.0.129", "192.168.2.186", "203.0.113.9"]
def test_local_ips_filters_loopback_and_ipv6(om, monkeypatch):
_fake_psutil(
monkeypatch,
{"Loopback": ["127.0.0.1", "::1"], "Ethernet": ["192.168.2.186", "fe80::1"]},
)
monkeypatch.setattr(om, "_route_ip", lambda: None)
assert om._local_ips() == ["192.168.2.186"]
def test_route_ip_absent_from_candidates_is_ignored(om, monkeypatch):
"""路由出口不在网卡表里(异常环境)时不该崩,也不该把结果搞乱。"""
_fake_psutil(monkeypatch, {"Ethernet": ["192.168.2.186"]})
monkeypatch.setattr(om, "_route_ip", lambda: "10.9.9.9")
assert om._local_ips() == ["192.168.2.186"]
def test_route_ip_smoke(om):
"""真跑一次 UDP connect(不发包):有默认路由时应给出本机 IPv4。"""
ip = om._route_ip()
assert ip is None or (ip.count(".") == 3 and not ip.startswith("127."))
# ------------------------------------------------------------ 发送方式
def _clear_mode_env(monkeypatch) -> None:
for key in ("HEXI_MEDIA_MODE", "HEXI_INLINE_MEDIA", "HEXI_INLINE_MEDIA_URL"):
monkeypatch.delenv(key, raising=False)
@pytest.mark.parametrize(
("env", "expected"),
[
# 默认 http(协议端跨机来拉)
({}, "http"),
({"HEXI_MEDIA_MODE": "http"}, "http"),
({"HEXI_MEDIA_MODE": " BASE64 "}, "base64"), # 大小写/空白容忍
({"HEXI_MEDIA_MODE": "file"}, "file"),
# 新键没配时按旧开关推断(老 .env 行为不变)
({"HEXI_INLINE_MEDIA": "false"}, "file"),
({"HEXI_INLINE_MEDIA_URL": "0"}, "base64"),
({"HEXI_INLINE_MEDIA": "false", "HEXI_INLINE_MEDIA_URL": "false"}, "file"),
# 新键优先于旧开关
({"HEXI_MEDIA_MODE": "http", "HEXI_INLINE_MEDIA": "false"}, "http"),
# 非法值退回旧开关/默认
({"HEXI_MEDIA_MODE": "ftp"}, "http"),
({"HEXI_MEDIA_MODE": "ftp", "HEXI_INLINE_MEDIA_URL": "false"}, "base64"),
],
)
def test_mode_resolution(om, monkeypatch, env, expected):
_clear_mode_env(monkeypatch)
for key, value in env.items():
monkeypatch.setenv(key, value)
assert om._mode() == expected
async def test_file_mode_passes_through(om, media_file: Path, monkeypatch):
"""file 模式(协议端就在本机)完全不改写:消息与文件上传都原样交给适配器。"""
monkeypatch.setenv("HEXI_MEDIA_MODE", "file")
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
assert await om.rewrite_file(str(media_file)) is None
assert await om.rewrite_upload_file(str(media_file)) is None
async def test_base64_mode_skips_url(om, media_file: Path, monkeypatch):
"""base64 模式即使媒体服务可用也不发 URL(协议端拨不通本机的场合)。"""
monkeypatch.setenv("HEXI_MEDIA_MODE", "base64")
monkeypatch.setattr(om, "_mounted", True)
monkeypatch.setenv("HEXI_MEDIA_BASE_URL", "http://192.168.2.15:39697")
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
# 文件上传不做 base64(太大)→ base64 模式下只能原样放行
assert await om.rewrite_upload_file(str(media_file)) is None
async def test_http_mode_still_falls_back_to_base64(om, media_file: Path, monkeypatch):
"""http 模式拿不到可达地址时仍回退 base64(没挂上媒体服务/无网卡)。"""
monkeypatch.setenv("HEXI_MEDIA_MODE", "http")
monkeypatch.setattr(om, "_mounted", False)
assert await om.rewrite_file(str(media_file)) == "base64://aGVsbG8="
# ------------------------------------------------------------ 改写目标
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) # 没被改写
# ------------------------------------------------------------ 伪协议还原
@pytest.mark.parametrize(
("value", "expected"),
[
# JPEG 的 base64 载荷恒以 /9j/ 开头,alconna 构造 Image(url=...) 时
# 会因 netloc 为空补上 https:// 前缀(2026-10-10 实测 fetch failed 的根因)
("https://base64:///9j/4AAQSkZJRg", "base64:///9j/4AAQSkZJRg"),
("http://base64:///9j/4AAQSkZJRg", "base64:///9j/4AAQSkZJRg"),
("https://file:///D:/x.jpg", "file:///D:/x.jpg"),
],
)
def test_repair_mangled_pseudo_scheme(om, value: str, expected: str):
assert om.repair_mangled_url(value) == expected
@pytest.mark.parametrize(
"value",
[
# 裸域名补 https:// 是 alconna 的本意,正常链接一律不碰
"https://example.com/a.jpg",
# 主机名恰好是 base64 的正常链接不能误伤(只有 base64:// 才算伪协议)
"https://base64.com/a.png",
# 没被补头的原值无需还原
"base64:///9j/4AAQSkZJRg",
"file:///D:/x.jpg",
None,
123,
],
)
def test_repair_leaves_normal_values_alone(om, value):
assert om.repair_mangled_url(value) is None
async def test_walk_repairs_mangled_base64(om, monkeypatch):
"""补头后的 base64 值进了 file 字段,walk 必须还原回伪协议本身。"""
monkeypatch.setattr(om, "_mounted", False)
seg = {"type": "image", "data": {"file": "https://base64:///9j/4AAQSkZJRg"}}
await om._walk(seg)
assert seg["data"]["file"] == "base64:///9j/4AAQSkZJRg"
async def test_walk_repairs_real_jpeg_base64(om, tmp_path: Path, monkeypatch):
"""用真实 JPEG 魔数验证前提:载荷以 /9j/ 开头 → urlparse 解析不出 hostname
→ 正是 alconna 会补 https:// 的情形;还原后应与原始 base64:// 串一致。"""
import base64 as _b64
from urllib.parse import urlparse
fp = tmp_path / "a.jpg"
fp.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 20)
payload = _b64.b64encode(fp.read_bytes()).decode()
assert payload.startswith("/9j/") # JPEG 恒如此,补头的前提
assert urlparse(f"base64://{payload}").hostname is None # netloc 为空才会被补头
monkeypatch.setattr(om, "_mounted", False)
seg = {"type": "image", "data": {"file": f"https://base64://{payload}"}}
await om._walk(seg)
assert seg["data"]["file"] == f"base64://{payload}"
# ------------------------------------------------------------ 钩子
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_repairs_mangled_value_even_in_file_mode(
om, media_file: Path, monkeypatch
):
"""file 模式不改写本地文件,但构造期就坏掉的伪协议任何模式都该修。
2026-10-10 老婆图报 `ActionFailed ... fetch failed` 发的就是这种值:
与协议端在哪台机器无关,不还原则必死。
"""
monkeypatch.setenv("HEXI_MEDIA_MODE", "file")
data = {
"message": Message(
[
MessageSegment.image(media_file),
MessageSegment.image("https://base64:///9j/4AAQSkZJRg"),
]
)
}
before = data["message"][0].data["file"]
await om._inline_outbound_media(_FakeBot(), "send_group_msg", data)
assert data["message"][0].data["file"] == before # 本地文件原样
assert data["message"][1].data["file"] == "base64:///9j/4AAQSkZJRg"
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