tests/test_metadata_amane_bridge.py
Rama master · réplica de lectura del repositorio original.
"""amane 桥脚本本身:一次子进程、一行 JSON、退出码分档。不装 amane,塞假的 Runtime 进去。"""
from __future__ import annotations
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
import zipfile
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "tools" / "amane-bridge" / "bridge.py"
def load_bridge():
spec = importlib.util.spec_from_file_location("amane_bridge_under_test", SCRIPT)
module = importlib.util.module_from_spec(spec)
# dataclass 解析 `from __future__ import annotations` 下的字段类型要在 sys.modules 里找到它。
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
bridge = load_bridge()
class FakeSourceError(Exception):
def __init__(self, reason, *, http_status=None, detail="", url=""):
super().__init__(detail)
self.reason, self.http_status, self.detail, self.url = reason, http_status, detail, url
class FakeMetadata:
def __init__(self, **fields):
self.fields = fields
def model_dump(self, mode="json"):
return dict(self.fields)
def make_runtime(outcomes):
"""`outcomes[站名]` 是要返回的资料、None(没有)或要抛的异常。"""
class Crawler:
site = ""
def __init__(self, *, client, config):
self.client, self.config = client, config
async def fetch(self, query, options):
outcome = outcomes[self.site]
if isinstance(outcome, BaseException):
raise outcome
return outcome
def crawler(site):
return type(f"{site}Crawler", (Crawler,), {"site": site})
return bridge.Runtime(
crawler=crawler, make_client=lambda proxy, timeout, rate: {"proxy": proxy},
query=lambda number: {"number": number}, options=lambda language: {"language": language},
source_error=FakeSourceError, version="0.16.1")
def run_main(argv, runtime):
out = io.StringIO()
code = bridge.main(argv, runtime=runtime, out=out)
lines = [line for line in out.getvalue().splitlines() if line.strip()]
return code, lines
class BridgeContractTests(unittest.TestCase):
def test_cli_outputs_utf8_even_when_redirected_streams_start_as_gbk(self):
wrapper = (
"import runpy, sys; "
"sys.stdout.reconfigure(encoding='gbk'); "
"sys.stderr.reconfigure(encoding='gbk'); "
"sys.argv = sys.argv[1:]; "
"runpy.run_path(sys.argv[0], run_name='__main__')"
)
for arguments in ([], ['--未知参数']):
with self.subTest(arguments=arguments):
result = subprocess.run([sys.executable, '-I', '-c', wrapper, str(SCRIPT), *arguments],
capture_output=True, check=False, timeout=30)
self.assertEqual(result.returncode, bridge.EXIT_USAGE)
if not arguments:
self.assertEqual(json.loads(result.stdout.decode('utf-8')),
{'error': '番号与站名都不能为空'})
else:
self.assertIn('--未知参数', result.stderr.decode('utf-8'))
def test_self_check_loads_every_site_without_creating_a_client(self):
runtime = make_runtime({})
factory = mock.Mock(side_effect=AssertionError("离线自检不能创建 HTTP 客户端"))
crawler = mock.Mock(side_effect=runtime.crawler)
runtime = bridge.Runtime(crawler=crawler, make_client=factory, query=runtime.query,
options=runtime.options, source_error=runtime.source_error,
version="0.20.0")
code, lines = run_main(["--self-check"], runtime)
self.assertEqual(code, 0)
self.assertEqual(len(lines), 1)
self.assertEqual(json.loads(lines[0]), {
"ok": True, "amane": {"version": "0.20.0"}, "sites": list(bridge.SITES)})
self.assertEqual(crawler.call_args_list, [mock.call(site) for site in bridge.SITES])
factory.assert_not_called()
def test_self_check_reports_missing_site_dependencies(self):
runtime = make_runtime({})
runtime = bridge.Runtime(crawler=mock.Mock(side_effect=ImportError("missing parser")),
make_client=runtime.make_client, query=runtime.query,
options=runtime.options, source_error=runtime.source_error)
code, lines = run_main(["--self-check"], runtime)
self.assertEqual(code, bridge.EXIT_FAILED)
self.assertFalse(json.loads(lines[0])["ok"])
self.assertIn("ImportError: missing parser", json.loads(lines[0])["error"])
def test_mixed_site_networks_use_the_requested_client(self):
seen = {}
class Crawler:
def __init__(self, *, client, config):
self.client = client
async def fetch(self, query, options):
seen[self.site] = self.client['proxy']
return None
runtime = bridge.Runtime(
crawler=lambda site: type(site, (Crawler,), {"site": site}),
make_client=lambda proxy, timeout, rate: {"proxy": proxy},
query=lambda number: number, options=lambda language: None,
source_error=FakeSourceError)
code, _ = run_main(["--number", "ABW-032", "--sites", "airav,freejavbt",
"--proxy", "http://127.0.0.1:7890", "--direct-sites", "airav"], runtime)
self.assertEqual(code, bridge.EXIT_NOT_FOUND)
self.assertEqual(seen, {"airav": None, "freejavbt": "http://127.0.0.1:7890"})
def test_failure_reasons_have_unique_values(self):
self.assertEqual(len(bridge.FAILURE_REASONS), 18)
self.assertEqual(len(set(bridge.FAILURE_REASONS)), 18)
def test_a_login_requirement_keeps_its_failure_reason(self):
runtime = make_runtime({"airav": FakeSourceError("login_required", detail="login page")})
code, lines = run_main(["--number", "ABW-032", "--sites", "airav"], runtime)
self.assertEqual(code, bridge.EXIT_FAILED)
record = json.loads(lines[0])["sites"]["airav"]
self.assertEqual(record["status"], "failed")
self.assertEqual(record["reason"], "login_required")
self.assertEqual(record["detail"], "login page")
def test_a_hit_prints_one_json_line_and_exits_zero(self):
runtime = make_runtime({
"fc2club": FakeMetadata(number="FC2-PPV-1", title="標題", actors=[{"name": "A", "gender": "female"}]),
"avsox": None,
})
code, lines = run_main(["--number", "FC2-PPV-1", "--sites", "fc2club,avsox"], runtime)
self.assertEqual(code, bridge.EXIT_FOUND)
self.assertEqual(len(lines), 1)
report = json.loads(lines[0])
self.assertEqual(report["number"], "FC2-PPV-1")
self.assertEqual(report["amane"], {"version": "0.16.1"})
self.assertEqual(report["sites"]["fc2club"]["status"], "found")
self.assertEqual(report["sites"]["fc2club"]["metadata"]["title"], "標題")
self.assertEqual(report["sites"]["avsox"], {"status": "not_found", "reason": "not_found",
"elapsed_s": report["sites"]["avsox"]["elapsed_s"]})
def test_all_misses_exit_two_and_any_error_without_a_hit_exits_three(self):
runtime = make_runtime({"avsox": None, "airav": None})
code, _ = run_main(["--number", "ABW-1", "--sites", "avsox,airav"], runtime)
self.assertEqual(code, bridge.EXIT_NOT_FOUND)
runtime = make_runtime({
"avsox": None,
"airav": FakeSourceError("cloudflare_blocked", http_status=403, detail="blocked", url="https://x/y"),
})
code, lines = run_main(["--number", "ABW-1", "--sites", "avsox,airav"], runtime)
self.assertEqual(code, bridge.EXIT_FAILED)
record = json.loads(lines[0])["sites"]["airav"]
self.assertEqual(record["status"], "failed")
self.assertEqual(record["reason"], "cloudflare_blocked")
self.assertEqual(record["http_status"], 403)
self.assertEqual(record["url"], "https://x/y")
def test_an_unknown_reason_is_kept_in_detail_under_unexpected(self):
record = bridge.failure_record(FakeSourceError("brand_new_reason", detail="x" * 600))
self.assertEqual(record["reason"], "unexpected")
self.assertTrue(record["detail"].startswith("brand_new_reason: xxx"))
self.assertLessEqual(len(record["detail"]), 500)
def test_a_crash_inside_one_site_does_not_take_the_batch_down(self):
runtime = make_runtime({"avsox": RuntimeError("boom"), "freejavbt": FakeMetadata(number="ABW-1", title="t")})
code, lines = run_main(["--number", "ABW-1", "--sites", "avsox,freejavbt"], runtime)
self.assertEqual(code, bridge.EXIT_FOUND)
report = json.loads(lines[0])
self.assertEqual(report["sites"]["avsox"]["reason"], "unexpected")
self.assertIn("RuntimeError: boom", report["sites"]["avsox"]["detail"])
def test_usage_errors_exit_four_with_a_json_error(self):
runtime = make_runtime({})
code, lines = run_main(["--number", "ABW-1", "--sites", "nosuchsite"], runtime)
self.assertEqual(code, bridge.EXIT_USAGE)
self.assertEqual(json.loads(lines[0]), {"error": "未知站名:nosuchsite"})
code, lines = run_main(["--number", " ", "--sites", "avsox"], runtime)
self.assertEqual(code, bridge.EXIT_USAGE)
self.assertIn("error", json.loads(lines[0]))
code, _ = run_main(["--sites", "avsox"], runtime)
self.assertEqual(code, bridge.EXIT_USAGE)
def test_the_proxy_reaches_the_client_factory(self):
seen = {}
def make_client(proxy, timeout, rate):
seen.update(proxy=proxy, timeout=timeout, rate=rate)
return None
runtime = make_runtime({"avsox": None})
runtime = bridge.Runtime(crawler=runtime.crawler, make_client=make_client, query=runtime.query,
options=runtime.options, source_error=runtime.source_error)
run_main(["--number", "ABW-1", "--sites", "avsox", "--proxy", "http://127.0.0.1:7897",
"--timeout", "12", "--rate", "1.5"], runtime)
self.assertEqual(seen, {"proxy": "http://127.0.0.1:7897", "timeout": 12.0, "rate": 1.5})
class OfficialSiteTests(unittest.TestCase):
"""官方档的五站(ADR-0048)走同一份 JSON 契约;`makers` 是 amane 的 `official` 模块。"""
def test_the_official_sites_name_their_amane_crawlers(self):
self.assertEqual(bridge.SITES["makers"], ("official", "OfficialCrawler"))
self.assertEqual(bridge.SITES["prestige"], ("prestige", "PrestigeCrawler"))
self.assertEqual(bridge.SITES["faleno"], ("faleno", "FalenoCrawler"))
self.assertEqual(bridge.SITES["dahlia"], ("dahlia", "DahliaCrawler"))
self.assertEqual(bridge.SITES["mgstage"], ("mgstage", "MGStageCrawler"))
self.assertNotIn("official", bridge.SITES)
def test_each_official_site_reports_hit_miss_and_refusal_on_its_own(self):
runtime = make_runtime({
"makers": FakeMetadata(number="SSIS-057", title="標題", studio="S1 NO.1 STYLE", release="2021-05-07"),
"prestige": FakeSourceError("http_error", http_status=403, detail="Forbidden",
url="https://www.prestige-av.com/api/product/search"),
"faleno": None,
"dahlia": FakeSourceError("geo_restricted", http_status=403, detail="not available in your region"),
"mgstage": FakeSourceError("age_verification", detail="adc cookie"),
})
code, lines = run_main(["--number", "SSIS-057", "--sites", "makers,prestige,faleno,dahlia,mgstage"],
runtime)
self.assertEqual(code, bridge.EXIT_FOUND)
sites = json.loads(lines[0])["sites"]
self.assertEqual(sites["makers"]["metadata"]["studio"], "S1 NO.1 STYLE")
self.assertEqual((sites["prestige"]["reason"], sites["prestige"]["http_status"]), ("http_error", 403))
self.assertEqual(sites["faleno"]["status"], "not_found")
self.assertEqual(sites["dahlia"]["reason"], "geo_restricted")
self.assertEqual(sites["mgstage"]["reason"], "age_verification")
def test_a_refused_maker_site_alone_is_a_failure_not_a_miss(self):
"""被挡与没有分开报:只问 Prestige 且被 403 挡住时退出码是 3,不是 2。"""
runtime = make_runtime({"prestige": FakeSourceError("http_error", http_status=403)})
code, _ = run_main(["--number", "ABW-032", "--sites", "prestige"], runtime)
self.assertEqual(code, bridge.EXIT_FAILED)
class RuntimePackagingTests(unittest.TestCase):
def setUp(self):
spec = importlib.util.spec_from_file_location(
"amane_runtime_builder_under_test", ROOT / "scripts/build_amane_runtime.py")
self.builder = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.builder)
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name).resolve()
def manifest(self, kind):
facts = {**self.builder.locked_amane(), "kind": kind,
"executable": "runtime.exe" if kind == "executable" else ".venv/bin/python"}
(self.root / "manifest.json").write_text(json.dumps(facts), encoding="utf-8")
return facts
def test_locked_runtime_uses_the_existing_revision_and_build_toolchain(self):
facts = self.builder.locked_amane()
self.assertEqual(facts, {"revision": "fd9294fbe5b4aa88ce1df84d534f6816ad9460d5",
"version": "0.22.1"})
requirements = self.builder.build_requirements()
self.assertIn("pyinstaller==6.22.3", requirements)
self.assertTrue(all("==" in item for item in requirements))
self.assertNotIn("packaging", [item.split("==")[0] for item in requirements])
def test_runtime_self_check_dispatches_both_packaging_kinds(self):
for kind in ("python", "executable"):
facts = self.manifest(kind)
report = {"ok": True, "amane": {"version": facts["version"]}, "sites": list(bridge.SITES)}
with mock.patch.object(self.builder.subprocess, "run", return_value=mock.Mock(
stdout=json.dumps(report))) as run:
self.assertEqual(self.builder.verify(self.root), report)
command = run.call_args.args[0]
self.assertEqual(command[0], str(self.root / facts["executable"]))
self.assertEqual(command[-1], "--self-check")
self.assertEqual("-X" in command, kind == "python")
self.assertNotIn("PYTHONPATH", run.call_args.kwargs["env"])
self.assertEqual(run.call_args.kwargs["env"]["PATH"], "")
self.assertEqual(run.call_args.kwargs["env"]["PYINSTALLER_RESET_ENVIRONMENT"], "1")
def test_desktop_smoke_requires_the_service_to_use_the_bundled_runtime(self):
from scripts import smoke_desktop
manifest = self.manifest('executable')
facts = {'installed': True, 'problem': '', 'revision': manifest['revision'],
'installed_version': manifest['version'], 'python': str(self.root / manifest['executable'])}
opener = mock.Mock()
with mock.patch.dict(sys.modules, {'build_amane_runtime': self.builder}), \
mock.patch.object(self.builder, 'verify') as verify:
opener.open.return_value = io.StringIO(json.dumps(facts))
smoke_desktop.assert_amane_runtime(opener, 'http://127.0.0.1:8900', self.root)
verify.assert_called_once_with(self.root)
opener.open.assert_called_once_with('http://127.0.0.1:8900/api/scraping/amane-bridge', timeout=15)
for change in ({'installed': False}, {'revision': 'foreign'}, {'installed_version': 'unknown'},
{'python': str(self.root / 'user-python')}, {'problem': '缺少运行环境'}):
with self.subTest(change=change):
opener.open.return_value = io.StringIO(json.dumps({**facts, **change}))
with self.assertRaises(AssertionError):
smoke_desktop.assert_amane_runtime(opener, 'http://127.0.0.1:8900', self.root)
def test_runtime_self_check_rejects_wrong_versions_and_process_failures(self):
self.manifest("executable")
with mock.patch.object(self.builder.subprocess, "run", return_value=mock.Mock(
stdout=json.dumps({"ok": True, "amane": {"version": "unknown"}}))):
with self.assertRaises(ValueError):
self.builder.verify(self.root)
with mock.patch.object(self.builder.subprocess, "run", side_effect=
subprocess.CalledProcessError(3, ["runtime.exe"])):
with self.assertRaises(subprocess.CalledProcessError):
self.builder.verify(self.root)
def test_runtime_self_check_rejects_foreign_revision_and_path_escape(self):
for change in ({"revision": "0" * 40}, {"executable": "../outside.exe"}, {"kind": "shell"}):
facts = {**self.manifest("executable"), **change}
(self.root / "manifest.json").write_text(json.dumps(facts), encoding="utf-8")
with mock.patch.object(self.builder.subprocess, "run") as run:
with self.assertRaises(ValueError):
self.builder.verify(self.root)
run.assert_not_called()
def test_build_rejects_work_inside_the_distribution(self):
with mock.patch.object(self.builder.subprocess, "run") as run:
with self.assertRaises(ValueError):
self.builder.build(self.root, work=self.root / "work")
run.assert_not_called()
def test_build_preserves_an_unowned_nonempty_destination(self):
private_file = self.root / "keep.txt"
private_file.write_text("keep", encoding="utf-8")
with mock.patch.object(self.builder.subprocess, "run") as run:
with self.assertRaises(ValueError):
self.builder.build(self.root, work=self.root.parent / "work")
run.assert_not_called()
self.assertEqual(private_file.read_text(encoding="utf-8"), "keep")
def test_docker_smoke_checks_the_bundled_runtime_and_matching_api_facts(self):
from scripts import smoke_docker
manifest = {**self.builder.locked_amane(), "kind": "python", "executable": ".venv/bin/python"}
report = {"ok": True, "amane": {"version": manifest["version"]}, "sites": list(bridge.SITES)}
description = {"python": "/app/amane-runtime/.venv/bin/python", "installed": True,
"revision": manifest["revision"]}
with mock.patch.object(smoke_docker, "docker", side_effect=[json.dumps(manifest), json.dumps(report)]) as docker, \
mock.patch.object(smoke_docker, "request", return_value=json.dumps(description)):
smoke_docker.amane_checks("fixture")
self.assertEqual(docker.call_args_list[1], mock.call(
"exec", "fixture", "/app/amane-runtime/.venv/bin/python", "-X", "utf8",
"/app/tools/amane-bridge/bridge.py", "--self-check"))
def test_windows_application_and_lite_payloads_keep_the_complete_bridge(self):
from peach.update_packages import TOOL_PATHS
from scripts.package_windows_release import package, build_lite_installer
payload = self.root / "Peach"
(payload / "_internal").mkdir(parents=True)
(payload / "Peach.exe").write_bytes(b"application")
(payload / "_internal/standalone.txt").touch()
runtime_files = {"amane-bridge.exe", "manifest.json", "LICENSE-amane.txt",
"amane-corresponding-source.zip", "_internal/python314.dll"}
runtime = payload / "_internal/tools/amane-runtime"
for name in runtime_files:
target = runtime / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(name.encode())
for prefix, required in TOOL_PATHS.values():
for name in required:
target = payload / prefix / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"tool")
release = self.root / "release"
package(payload, release, "1.0.0")
expected = {"Peach/_internal/tools/amane-runtime/" + name for name in runtime_files}
for suffix in (".zip", "-update.zip"):
with zipfile.ZipFile(release / f"Peach-1.0.0-windows-x64{suffix}") as archive:
self.assertTrue(expected <= set(archive.namelist()))
for tool in TOOL_PATHS:
with zipfile.ZipFile(release / f"Peach-1.0.0-windows-x64-tools-{tool}.zip") as archive:
self.assertFalse(expected & set(archive.namelist()))
def compile_installer(command, **kwargs):
source = Path(next(arg.removeprefix("/DSourceDir=") for arg in command
if arg.startswith("/DSourceDir=")))
for name in runtime_files:
self.assertEqual((source / "_internal/tools/amane-runtime" / name).read_bytes(), name.encode())
self.assertIn("/DLiteInstaller=1", command)
(release / "Peach-1.0.0-windows-x64-lite-setup.exe").write_bytes(b"installer")
with mock.patch("scripts.package_windows_release.subprocess.run", side_effect=compile_installer):
self.assertTrue(build_lite_installer(release, "1.0.0", self.root / "ISCC.exe").is_file())
if __name__ == "__main__":
unittest.main()