Implement P0 of the reaction dashboard: config loading with hot reload, SQLite migrations with backups, single-writer queue, read-only Bilibili fav client, folder import/diff sync, invalid-resource handling, blacklist seed, scheduler, and JSON redacted logging. Includes offline unit and transport-level contract tests; all existing and new tests pass.
211 lines
7.4 KiB
Python
211 lines
7.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""配置加载 / 校验 / 热加载测试(不联网): uv run python tests/test_config.py"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
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.config import ConfigError, ConfigStore, load_config # noqa: E402
|
|
|
|
COOKIE = "SESSDATA=secret%2C1800000000%2Cabc; bili_jct=csrf-secret; buvid3=dev"
|
|
|
|
|
|
def write(path: Path, data) -> None:
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
def bump_mtime(path: Path) -> None:
|
|
"""保证 mtime 严格大于上一次,避免 Windows 时间精度导致热加载测试偶发失败。"""
|
|
target = max(path.stat().st_mtime_ns, time.time_ns()) + 1_000_000
|
|
os.utime(path, ns=(target, target))
|
|
|
|
|
|
def base_legacy() -> dict:
|
|
return {
|
|
"mid": 28802351,
|
|
"csrf": "csrf-secret",
|
|
"cookie": COOKIE,
|
|
"overrides": {"1": {"mid": 2, "name": "x"}},
|
|
"sleep": {"min": 1},
|
|
"folders": [
|
|
{"year": 2025, "media_id": 4451053851, "title": "2025"},
|
|
{"year": 2026, "media_id": 3700023351, "title": "2026"},
|
|
],
|
|
"track_fans": [37754047],
|
|
"up_blacklist_seed": [1, 2, 2],
|
|
}
|
|
|
|
|
|
def test_legacy_flat_mapping():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "config.json"
|
|
write(path, base_legacy())
|
|
cfg = load_config(path, env={})
|
|
assert cfg.bilibili.mid == 28802351
|
|
assert cfg.bilibili.csrf == "csrf-secret"
|
|
assert cfg.bilibili.cookie == COOKIE
|
|
assert cfg.storage.database_path == Path(tmp) / "data" / "app.db"
|
|
assert cfg.storage.backup_dir == Path(tmp) / "data" / "backup"
|
|
assert cfg.schedule.snapshot_at == "00:01"
|
|
assert [f.media_id for f in cfg.folders] == [4451053851, 3700023351]
|
|
assert cfg.track_fans == (37754047,)
|
|
assert cfg.up_blacklist_seed == (1, 2) # 去重
|
|
assert cfg.raw["overrides"] # 旧字段仍保留给 favsort
|
|
assert set(cfg.heat_weights) == {
|
|
"view", "like", "coin", "favorite", "share", "reply", "danmaku"
|
|
}
|
|
|
|
|
|
def test_nested_and_env_override():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "config.json"
|
|
write(path, {
|
|
"bilibili": {"mid": 1, "csrf": "old", "cookie": "old-cookie"},
|
|
"storage": {"database_path": "var/app.db", "backup_dir": "var/bak"},
|
|
"folders": [],
|
|
})
|
|
env = {
|
|
"BILI_MID": "999",
|
|
"BILI_CSRF": "env-csrf",
|
|
"BILI_COOKIE": "env-cookie",
|
|
"TG_BOT_TOKEN": "123456:abcdefghijklmnopqrstuvwxyzABCDE",
|
|
"TG_CHAT_ID": "42",
|
|
"DASHBOARD_AUTH_TOKEN": "web-token",
|
|
"DATABASE_PATH": "custom.db",
|
|
}
|
|
cfg = load_config(path, env=env)
|
|
assert cfg.bilibili.mid == 999
|
|
assert cfg.bilibili.csrf == "env-csrf"
|
|
assert cfg.bilibili.cookie == "env-cookie"
|
|
assert cfg.telegram.chat_id == 42
|
|
assert cfg.telegram.bot_token.startswith("123456:")
|
|
assert cfg.web.auth_token == "web-token"
|
|
assert cfg.storage.database_path == Path(tmp) / "custom.db"
|
|
# 备份目录仍按配置文件目录解析
|
|
assert cfg.storage.backup_dir == Path(tmp) / "var" / "bak"
|
|
|
|
|
|
def test_absolute_paths_kept():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
db = str(Path(tmp) / "abs" / "app.db")
|
|
path = Path(tmp) / "config.json"
|
|
write(path, {
|
|
"bilibili": {"mid": 1, "csrf": "c", "cookie": "k"},
|
|
"storage": {"database_path": db, "backup_dir": str(Path(tmp) / "b")},
|
|
"folders": [],
|
|
})
|
|
cfg = load_config(path, env={})
|
|
assert cfg.storage.database_path == Path(db)
|
|
|
|
|
|
def test_invalid_configs():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "config.json"
|
|
cases = [
|
|
({"folders": [{"year": 2025, "media_id": 1},
|
|
{"year": 2026, "media_id": 1}]}, "media_id 重复"),
|
|
({"folders": [{"year": 2025, "media_id": 1},
|
|
{"year": 2025, "media_id": 2}]}, "year 重复"),
|
|
({"folders": [{"year": 2025, "media_id": 0}]}, "不能小于 1"),
|
|
({"schedule": {"snapshot_at": "24:00"}}, "snapshot_at"),
|
|
({"schedule": {"snapshot_at": "1:01"}}, "snapshot_at"),
|
|
({"notify": {"max_items_per_message": 0}}, "max_items_per_message"),
|
|
({"heat_weights": {"view": -1}}, "heat_weights.view"),
|
|
({"classify": {"require_title": ""}}, "require_title"),
|
|
]
|
|
base = {
|
|
"bilibili": {"mid": 1, "csrf": "c", "cookie": "k"},
|
|
"folders": [],
|
|
}
|
|
for patch, needle in cases:
|
|
data = json.loads(json.dumps(base))
|
|
data.update(patch)
|
|
write(path, data)
|
|
try:
|
|
load_config(path, env={})
|
|
except ConfigError as exc:
|
|
assert needle in str(exc), (needle, str(exc))
|
|
else:
|
|
raise AssertionError(f"应当失败: {patch}")
|
|
|
|
|
|
def test_require_auth():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "config.json"
|
|
write(path, {"bilibili": {"mid": 1, "csrf": "", "cookie": ""}, "folders": []})
|
|
load_config(path, env={}, require_auth=False)
|
|
try:
|
|
load_config(path, env={}, require_auth=True)
|
|
except ConfigError as exc:
|
|
assert "cookie" in str(exc)
|
|
else:
|
|
raise AssertionError("require_auth 应拒绝空凭据")
|
|
|
|
|
|
def test_env_int_invalid():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "config.json"
|
|
write(path, base_legacy())
|
|
try:
|
|
load_config(path, env={"BILI_MID": "abc"})
|
|
except ConfigError as exc:
|
|
assert "BILI_MID" in str(exc)
|
|
else:
|
|
raise AssertionError("非法整数环境变量应失败")
|
|
|
|
|
|
def test_hot_reload_keeps_last_good_config():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / "config.json"
|
|
write(path, base_legacy())
|
|
store = ConfigStore(path, env={})
|
|
assert store.get().folders[0].title == "2025"
|
|
|
|
data = base_legacy()
|
|
data["folders"][0]["title"] = "2025-新"
|
|
write(path, data)
|
|
bump_mtime(path)
|
|
assert store.get().folders[0].title == "2025-新"
|
|
assert store.last_error is None
|
|
|
|
path.write_text("{not json", encoding="utf-8")
|
|
bump_mtime(path)
|
|
assert store.get().folders[0].title == "2025-新"
|
|
assert store.last_error is not None
|
|
|
|
write(path, data)
|
|
bump_mtime(path)
|
|
assert store.get().folders[0].title == "2025-新"
|
|
assert store.last_error is None
|
|
|
|
|
|
def test_missing_file():
|
|
try:
|
|
load_config(Path(tempfile.gettempdir()) / "no-such-bili-fav-config.json", env={})
|
|
except ConfigError as exc:
|
|
assert "缺少配置文件" in str(exc)
|
|
else:
|
|
raise AssertionError("缺失配置应失败")
|
|
|
|
|
|
def main():
|
|
test_legacy_flat_mapping()
|
|
test_nested_and_env_override()
|
|
test_absolute_paths_kept()
|
|
test_invalid_configs()
|
|
test_require_auth()
|
|
test_env_int_invalid()
|
|
test_hot_reload_keeps_last_good_config()
|
|
test_missing_file()
|
|
print("OK: 配置加载、校验与热加载测试全部通过")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|