# -*- 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()