Backfill same-day snapshots for newly added or restored videos, preserve successful fan snapshots on resume, persist incomplete state before forced re-runs, and split collection completion from backup completion so failed backups can retry without API calls. Reject missing data payloads instead of treating them as empty folders, guard against mass-unfav on suspicious empty remote folders, rebuild Bilibili clients when credentials change, recompute primary folder ownership from active relations with per-folder fav_time, preserve original titles for dropped invalid videos, poll config every 60 seconds, and fix favsort verify interval and mixed resource types.
226 lines
7.6 KiB
Python
226 lines
7.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""CLI 入口测试(离线): uv run python tests/test_cli.py"""
|
|
|
|
import contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
from reaction_board.bilibili.models import LoginState # noqa: E402
|
|
from reaction_board.cli import main # noqa: E402
|
|
from reaction_board.database import Database # noqa: E402
|
|
from reaction_board.repository import Repository # noqa: E402
|
|
import reaction_board.cli as cli # noqa: E402
|
|
|
|
|
|
def write_config(tmp, *, cookie="SESSDATA=x; buvid3=y", csrf="csrf"):
|
|
path = Path(tmp) / "config.json"
|
|
path.write_text(json.dumps({
|
|
"bilibili": {
|
|
"read": {"mid": 1, "csrf": csrf, "cookie": cookie},
|
|
"write": {"mid": 2, "csrf": "write-csrf", "cookie": "write-cookie"},
|
|
},
|
|
"storage": {
|
|
"database_path": str(Path(tmp) / "app.db"),
|
|
"backup_dir": str(Path(tmp) / "backup"),
|
|
},
|
|
"folders": [
|
|
{"year": 2025, "media_id": 11, "title": "2025"},
|
|
{"year": 2026, "media_id": 22, "title": "2026"},
|
|
],
|
|
"up_blacklist_seed": [101, 202],
|
|
}, ensure_ascii=False), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def run_cli(argv):
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
code = main(argv)
|
|
return code, out.getvalue()
|
|
|
|
|
|
def test_init_and_status():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = write_config(tmp)
|
|
code, output = run_cli(["init", "--config", str(path)])
|
|
assert code == 0
|
|
assert '"schema_version": 2' in output
|
|
|
|
db = Database(Path(tmp) / "app.db")
|
|
db.initialize()
|
|
try:
|
|
repo = Repository(db)
|
|
assert len(repo.list_folders()) == 2
|
|
assert [r["mid"] for r in repo.blacklist()] == [101, 202]
|
|
assert repo.counts()["video_total"] == 0
|
|
finally:
|
|
db.close()
|
|
|
|
code, output = run_cli(["status", "--config", str(path)])
|
|
assert code == 0
|
|
assert '"configured_folders"' in output
|
|
assert '"media_count": null' in output
|
|
assert '"video_total": 0' in output
|
|
|
|
|
|
def test_missing_config():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
code, output = run_cli(["init", "--config", str(Path(tmp) / "nope.json")])
|
|
assert code == 2
|
|
payload = json.loads(output.strip().splitlines()[-1])
|
|
assert payload["level"] == "error"
|
|
assert "缺少配置文件" in payload["error"]
|
|
|
|
|
|
def test_sync_requires_auth_without_network():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = write_config(tmp, cookie="", csrf="")
|
|
code, output = run_cli(["sync", "--config", str(path)])
|
|
assert code == 2
|
|
payload = json.loads(output.strip().splitlines()[-1])
|
|
assert "cookie" in payload["error"]
|
|
assert not (Path(tmp) / "app.db").exists()
|
|
|
|
|
|
def test_snapshot_offline_empty_db():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = write_config(tmp, cookie="", csrf="")
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data["folders"] = []
|
|
data["track_fans"] = []
|
|
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
code, output = run_cli(["snapshot", "--config", str(path)])
|
|
assert code == 0
|
|
assert '"completed": true' in output
|
|
assert '"total": 0' in output
|
|
assert len(list((Path(tmp) / "backup").glob("app-*.db"))) == 1
|
|
|
|
|
|
def test_auth_check_without_cookie():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = write_config(tmp, cookie="", csrf="")
|
|
code, output = run_cli(["auth-check", "--config", str(path)])
|
|
assert code == 1
|
|
payload = json.loads(output)
|
|
assert payload["is_login"] is False
|
|
assert "未配置 cookie" in payload["message"]
|
|
|
|
|
|
def test_auth_check_all_and_auth_dispatch():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = write_config(tmp, cookie="", csrf="")
|
|
code, output = run_cli(["auth-check", "--account", "all", "--config", str(path)])
|
|
assert code == 1
|
|
payload = json.loads(output)
|
|
assert payload["ok"] is False
|
|
assert [item["account"] for item in payload["accounts"]] == ["read", "write"]
|
|
|
|
original = cli.AuthService
|
|
calls = []
|
|
|
|
class FakeAuthService:
|
|
def __init__(self, path, *, account, logger):
|
|
calls.append((str(path), account))
|
|
|
|
def run(self, *, timeout, no_browser):
|
|
assert timeout == 9 and no_browser is True
|
|
return 7
|
|
|
|
cli.AuthService = FakeAuthService
|
|
try:
|
|
code, _ = run_cli([
|
|
"auth", "--account", "write", "--timeout", "9", "--no-browser", "--config", str(path),
|
|
])
|
|
finally:
|
|
cli.AuthService = original
|
|
assert code == 7
|
|
assert calls == [(str(path), "write")]
|
|
|
|
|
|
def test_run_once_offline():
|
|
class FakeClient:
|
|
def check_login(self):
|
|
return LoginState(True, 1, "tester", 0, "OK")
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = write_config(tmp)
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data["folders"] = []
|
|
data["track_fans"] = []
|
|
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
|
|
original = cli._client
|
|
selected = []
|
|
|
|
def fake_client(credentials, *args, **kwargs):
|
|
selected.append(credentials)
|
|
return FakeClient()
|
|
|
|
cli._client = fake_client
|
|
try:
|
|
code, output = run_cli(["run", "--once", "--config", str(path)])
|
|
finally:
|
|
cli._client = original
|
|
assert code == 0
|
|
assert '"name": "folder_sync"' in output
|
|
assert '"name": "snapshot"' in output
|
|
assert selected and all(credentials.mid == 1 for credentials in selected)
|
|
|
|
|
|
def test_snapshot_backup_failure_returns_error_and_retries():
|
|
import reaction_board.services.snapshot as snapshot_module
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = write_config(tmp, cookie="", csrf="")
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
data["folders"] = []
|
|
data["track_fans"] = []
|
|
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
|
|
original = snapshot_module.backup_daily
|
|
|
|
def fail_backup(*args, **kwargs):
|
|
raise OSError("disk full")
|
|
|
|
snapshot_module.backup_daily = fail_backup
|
|
try:
|
|
code, output = run_cli(["snapshot", "--config", str(path)])
|
|
finally:
|
|
snapshot_module.backup_daily = original
|
|
assert code == 1
|
|
marker = output.find("{\n")
|
|
assert marker >= 0, output
|
|
payload = json.loads(output[marker:])
|
|
assert payload["completed"] is True
|
|
assert "disk full" in payload["backup_error"]
|
|
|
|
code, output = run_cli(["snapshot", "--config", str(path)])
|
|
assert code == 0
|
|
marker = output.find("{\n")
|
|
payload = json.loads(output[marker:])
|
|
assert payload["backup_error"] is None
|
|
assert Path(payload["backup_path"]).exists()
|
|
|
|
|
|
def main_test():
|
|
test_init_and_status()
|
|
test_missing_config()
|
|
test_sync_requires_auth_without_network()
|
|
test_snapshot_offline_empty_db()
|
|
test_auth_check_without_cookie()
|
|
test_auth_check_all_and_auth_dispatch()
|
|
test_run_once_offline()
|
|
test_snapshot_backup_failure_returns_error_and_retries()
|
|
print("OK: CLI init/sync/snapshot/auth-check/run/status/错误路径测试全部通过")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main_test()
|