mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-29 03:36:15 +08:00
- apprise:urls 含令牌,改为 secret_field;secret_field 支持 default_factory,可声明列表 / 字典秘密字段
- run-command:stderr 改为自建管道边读边丢,只保留最后 2000 字节;完成仍只看 proc.wait(),结束后给读取 0.5 秒再关闭管道
- run-command:{files} 按原始路径列表展开,含换行的路径不再拆成两个参数
- 通知:插件渠道 extra 里的已声明字段与内置渠道一样展开 $VAR
- slack:只在 HTTP 400 invalid_blocks 时退回纯文字重试,其它失败不重发
- onebot:先发文字,再单独发海报;结果只看文字,去掉重试
- strm-writer:media_root 为 "/" 时正确映射完整路径
151 lines
5.3 KiB
Python
151 lines
5.3 KiB
Python
import asyncio
|
||
import json
|
||
import logging
|
||
import sys
|
||
import time
|
||
|
||
import pytest
|
||
from run_command import RunCommand
|
||
|
||
from ab_sdk.events import FileRenamed, OrganizedFile, TorrentOrganized
|
||
from ab_sdk.testing import create_plugin
|
||
|
||
ORGANIZED = TorrentOrganized(
|
||
torrent_hash="abc",
|
||
bangumi_id=None,
|
||
files=(
|
||
OrganizedFile("/dl/Show/Season 1/Show S01E01.mkv", "media"),
|
||
OrganizedFile("/dl/Show/Season 1/Show S01E01.ass", "subtitle"),
|
||
),
|
||
)
|
||
RENAMED = FileRenamed(
|
||
bangumi_id=7, old_path="/dl/a.mkv", new_path="/dl/b.mkv", file_kind="media"
|
||
)
|
||
|
||
# 子进程把收到的参数与 AB_* 环境变量写进 argv[1] 指定的文件
|
||
DUMP = (
|
||
"import json, os, sys; "
|
||
"json.dump({'argv': sys.argv[2:], "
|
||
"'env': {k: v for k, v in os.environ.items() if k.startswith('AB_')}}, "
|
||
"open(sys.argv[1], 'w'))"
|
||
)
|
||
|
||
|
||
def run(options: dict, event=ORGANIZED) -> None:
|
||
plugin, _ = create_plugin(RunCommand, options)
|
||
asyncio.run(plugin.on_event(event))
|
||
|
||
|
||
def test_on_event_organized_passes_env_and_placeholders_without_shell(
|
||
tmp_path, monkeypatch
|
||
):
|
||
# 宿主自己的 AB_* 凭据不传给命令
|
||
monkeypatch.setenv("AB_DOWNLOADER_PASSWORD", "s3cret")
|
||
out = tmp_path / "out.json"
|
||
command = [
|
||
*(sys.executable, "-c", DUMP, str(out)),
|
||
*("{torrent_hash}", "$HOME; {x}", "pre-{torrent_hash}", "{files}"),
|
||
]
|
||
run({"command": command})
|
||
got = json.loads(out.read_text())
|
||
# $HOME 与 ; 按字面传入:没有经过 shell;未知占位符与夹在参数里的占位符原样保留;
|
||
# {files} 展开为每个文件一个参数
|
||
assert got["argv"] == [
|
||
"abc",
|
||
"$HOME; {x}",
|
||
"pre-{torrent_hash}",
|
||
"/dl/Show/Season 1/Show S01E01.mkv",
|
||
"/dl/Show/Season 1/Show S01E01.ass",
|
||
]
|
||
assert got["env"] == {
|
||
"AB_EVENT": "torrent.organized",
|
||
"AB_BANGUMI_ID": "",
|
||
"AB_DOWNLOADER_ID": "default",
|
||
"AB_TORRENT_HASH": "abc",
|
||
"AB_FILES": "/dl/Show/Season 1/Show S01E01.mkv\n"
|
||
"/dl/Show/Season 1/Show S01E01.ass",
|
||
}
|
||
|
||
|
||
def test_on_event_renamed_when_selected_passes_rename_fields(tmp_path):
|
||
out = tmp_path / "out.json"
|
||
command = [sys.executable, "-c", DUMP, str(out), "{old_path}"]
|
||
run({"command": command, "events": ["file.renamed"]}, RENAMED)
|
||
got = json.loads(out.read_text())
|
||
assert got["argv"] == ["/dl/a.mkv"]
|
||
assert got["env"]["AB_BANGUMI_ID"] == "7"
|
||
assert got["env"]["AB_NEW_PATH"] == "/dl/b.mkv"
|
||
assert got["env"]["AB_FILE_KIND"] == "media"
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("options", "event"),
|
||
[({"command": []}, ORGANIZED), ({}, RENAMED)],
|
||
)
|
||
def test_on_event_unwanted_runs_nothing(tmp_path, options, event):
|
||
out = tmp_path / "out.json"
|
||
options.setdefault("command", [sys.executable, "-c", DUMP, str(out)])
|
||
run(options, event)
|
||
assert not out.exists()
|
||
|
||
|
||
def test_on_event_timeout_kills_command(caplog):
|
||
command = [sys.executable, "-c", "import time; time.sleep(30)"]
|
||
plugin, _ = create_plugin(RunCommand, {"command": command, "timeout": 1})
|
||
start = time.monotonic()
|
||
with caplog.at_level(logging.WARNING):
|
||
asyncio.run(plugin.on_event(ORGANIZED))
|
||
assert time.monotonic() - start < 10
|
||
assert "已终止" in caplog.text
|
||
|
||
|
||
def test_on_event_background_child_does_not_delay_success(caplog):
|
||
# 后台进程继承了 stderr;命令本身已退出,不应等到后台进程结束
|
||
command = ["sh", "-c", "sleep 5 & exit 0"]
|
||
plugin, _ = create_plugin(RunCommand, {"command": command, "timeout": 2})
|
||
start = time.monotonic()
|
||
with caplog.at_level(logging.INFO):
|
||
asyncio.run(plugin.on_event(ORGANIZED))
|
||
assert time.monotonic() - start < 2
|
||
assert "已完成" in caplog.text and "已终止" not in caplog.text
|
||
|
||
|
||
def test_on_event_files_placeholder_keeps_newline_in_path(tmp_path):
|
||
# {files} 取原始路径列表:含换行的路径仍是一个参数(AB_FILES 只能按行拼接)
|
||
out = tmp_path / "out.json"
|
||
event = TorrentOrganized(
|
||
torrent_hash="abc",
|
||
bangumi_id=1,
|
||
files=(OrganizedFile("/dl/a\nb.mkv", "media"),),
|
||
)
|
||
run({"command": [sys.executable, "-c", DUMP, str(out), "{files}"]}, event)
|
||
assert json.loads(out.read_text())["argv"] == ["/dl/a\nb.mkv"]
|
||
|
||
|
||
def test_on_event_huge_stderr_keeps_only_tail(caplog):
|
||
# 输出远超管道缓冲:边读边丢,命令不会卡到超时,日志只带最后 2000 字节
|
||
script = (
|
||
"import sys; sys.stderr.write('x' * 5_000_000 + 'END'); sys.stderr.flush(); "
|
||
"sys.exit(3)"
|
||
)
|
||
start = time.monotonic()
|
||
with caplog.at_level(logging.WARNING):
|
||
run({"command": [sys.executable, "-c", script], "timeout": 10})
|
||
assert time.monotonic() - start < 5
|
||
[record] = caplog.records
|
||
assert "退出码 3" in record.getMessage()
|
||
assert record.args[-1] == "x" * 1997 + "END"
|
||
|
||
|
||
def test_on_event_nonzero_exit_logs_code_and_stderr(caplog):
|
||
script = "import sys; sys.stderr.write('boom'); sys.exit(3)"
|
||
with caplog.at_level(logging.WARNING):
|
||
run({"command": [sys.executable, "-c", script]})
|
||
assert "退出码 3" in caplog.text and "boom" in caplog.text
|
||
|
||
|
||
def test_on_event_missing_program_is_logged_not_raised(caplog):
|
||
with caplog.at_level(logging.WARNING):
|
||
run({"command": ["/nonexistent/ab-run-command-test"]})
|
||
assert "无法启动命令" in caplog.text
|