feat(p0): add favorite folder sync and sqlite data foundation

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.
This commit is contained in:
kairee committed 2026-10-31 18:08:33 +08:00
commit e2cdf997af
39 files changed
+8575

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
# -*- coding: utf-8 -*-
"""收藏夹同步集成测试(用真实接口形状的 transport Mock,不联网):
uv run python tests/test_folder_sync.py
"""
import json
import os
import sys
import tempfile
import urllib.parse
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fixtures import info_for # noqa: E402
from reaction_board.bilibili.client import ( # noqa: E402
BilibiliClient,
HTTPResponse,
Pacer,
TransportError,
)
from reaction_board.config import ConfigStore # noqa: E402
from reaction_board.database import Database # noqa: E402
from reaction_board.repository import Repository # noqa: E402
from reaction_board.services.folder_sync import FolderSyncService # noqa: E402
F1 = 4451053851
F2 = 3700023351
def http_json(payload):
return HTTPResponse(
200, {"content-type": "application/json"},
json.dumps(payload, ensure_ascii=False).encode("utf-8"),
)
class FakeFavTransport:
def __init__(self, folders, infos, *, fail_media=None):
self.folders = {k: list(v) for k, v in folders.items()}
self.infos = dict(infos)
self.fail_media = fail_media
self.ids_calls = []
self.info_calls = []
def request(self, method, url, headers, data=None, timeout=30):
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
if "/x/v3/fav/resource/ids" in url:
media_id = int(query["media_id"][0])
self.ids_calls.append(media_id)
if self.fail_media == media_id:
raise TransportError("ids 网络错误")
data_rows = [
{"id": aid, "type": 2, "bv_id": f"BV{aid}", "bvid": f"BV{aid}"}
for aid in self.folders.get(media_id, [])
]
return http_json({"code": 0, "message": "0", "ttl": 1, "data": data_rows})
if "/x/v3/fav/resource/infos" in url:
media_id = int(query["folder_id"][0])
resources = query["resources"][0]
aids = [int(part.split(":")[0]) for part in resources.split(",")]
self.info_calls.append((media_id, aids))
rows = [self.infos[aid] for aid in aids if aid in self.infos]
return http_json({"code": 0, "message": "0", "ttl": 1, "data": rows})
if "/x/v3/fav/folder/created/list-all" in url:
return http_json({
"code": 0, "message": "0", "ttl": 1,
"data": {"count": 0, "list": []},
})
raise AssertionError(f"未预期的 URL: {url}")
def write_config(tmp, folders):
data = {
"mid": 28802351,
"csrf": "csrf-secret",
"cookie": "SESSDATA=secret; bili_jct=csrf-secret; buvid3=device",
"storage": {
"database_path": str(Path(tmp) / "app.db"),
"backup_dir": str(Path(tmp) / "backup"),
},
"folders": folders,
"up_blacklist_seed": [999],
"schedule": {
"discover_minutes": 60,
"folder_sync_minutes": 60,
"full_folder_refresh_hours": 6,
"snapshot_at": "00:01",
"candidate_ttl_days": 7,
},
}
path = Path(tmp) / "config.json"
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return path
def make_service(tmp, transport, clock, *, capacity_warn=950):
folders = [
{"year": 2025, "media_id": F1, "title": "2025"},
{"year": 2026, "media_id": F2, "title": "2026"},
]
path = write_config(tmp, folders)
store = ConfigStore(path, env={}, require_auth=True)
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
db.initialize()
repo = Repository(db, clock=lambda: clock[0])
client = BilibiliClient(
cookie="SESSDATA=secret; bvid3=device",
csrf="csrf",
mid=28802351,
transport=transport,
pacer=Pacer(0, 0),
retries=1,
)
service = FolderSyncService(
store, repo, client, clock=lambda: clock[0], capacity_warn=capacity_warn
)
return db, repo, service
def valid(aid, *, up_mid=100, title=None):
return info_for(aid, title=title or f"视频{aid}", up_mid=up_mid, up_name="UP")
def test_initial_import_and_incremental_skip():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102, 1103, 1104], F2: []},
{
1101: valid(1101),
1102: valid(1102, up_mid=999), # 黑名单 UP 也必须正常入库
1103: valid(1103, title="已失效", up_mid=100),
},
)
# 1103 标记为 attr=9;1104 静默缺失
transport.infos[1103]["attr"] = 9
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
summary = service.run()
assert summary.ok
assert summary.added == 4
assert summary.missing == 1
assert summary.refreshed == 3
assert repo.seed_blacklist([999], now=0) == 0 # 已由 service 写入
assert repo.get_video(1101)["status"] == "active"
assert repo.get_video(1102)["status"] == "active" # 黑名单不影响收藏夹导入
assert repo.get_video(1103)["status"] == "removed"
assert repo.get_video(1104)["status"] == "removed"
assert repo.get_video(1104)["bvid"] == "BV1104"
assert repo.folder_active_aids(F1) == {1101, 1102, 1103, 1104}
assert repo.is_blacklisted(999)
folder = repo.get_folder(F1)
assert folder.media_count == 4
assert folder.last_full_at == 1_800_000_000
# 6 小时内再次运行:没有新增,不请求 infos
clock[0] += 60
info_before = len(transport.info_calls)
summary2 = service.run()
assert summary2.added == 0
assert summary2.refreshed == 0
assert len(transport.info_calls) == info_before
assert summary2.folders[0].full_refresh is False
finally:
db.close()
def test_diff_add_and_remove():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102], F2: []},
{1101: valid(1101), 1102: valid(1102)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
service.run()
assert repo.get_video(1101)["status"] == "active"
assert repo.get_video(1102)["status"] == "active"
transport.folders[F1] = [1102, 1103]
transport.infos[1103] = valid(1103)
clock[0] += 3600
summary = service.run()
assert summary.added == 1
assert summary.removed == 1
assert summary.refreshed == 1
assert repo.get_video(1101)["status"] == "unfaved"
assert repo.get_video(1102)["status"] == "active"
assert repo.get_video(1103)["status"] == "active"
assert repo.folder_active_aids(F1) == {1102, 1103}
assert repo.get_folder(F1).media_count == 2
finally:
db.close()
def test_full_refresh_marks_invalid_and_missing():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101, 1102], F2: []},
{1101: valid(1101), 1102: valid(1102)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
service.run()
assert repo.get_video(1101)["status"] == "active"
assert repo.get_video(1102)["status"] == "active"
# 6 小时后:1101 失效,1102 静默缺失
clock[0] += 6 * 3600 + 1
transport.infos[1101]["attr"] = 9
del transport.infos[1102]
summary = service.run()
assert summary.folders[0].full_refresh is True
assert summary.missing == 1
assert repo.get_video(1101)["status"] == "removed"
assert repo.get_video(1102)["status"] == "removed"
assert repo.get_folder(F1).last_full_at == clock[0]
# 全量刷新请求了夹内全部 aid
assert set(transport.info_calls[-1][1]) == {1101, 1102}
finally:
db.close()
def test_capacity_warning_does_not_move_folder():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1, 2, 3], F2: []},
{i: valid(i) for i in (1, 2, 3)},
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock, capacity_warn=3)
try:
summary = service.run()
assert summary.folders[0].warning
assert "预警线 3" in summary.folders[0].warning
assert "不会自动换夹" in summary.folders[0].warning
# 视频仍在原夹,不被移动
assert all(repo.get_video(i)["folder_id"] == F1 for i in (1, 2, 3))
finally:
db.close()
def test_folder_error_does_not_block_other_folders():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport(
{F1: [1101], F2: [2201]},
{1101: valid(1101), 2201: valid(2201)},
fail_media=F1,
)
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
summary = service.run()
assert summary.ok is False
assert len(summary.errors) == 1 and str(F1) in summary.errors[0]
assert summary.folders[0].error
assert summary.folders[1].error is None
assert repo.get_video(2201)["status"] == "active"
finally:
db.close()
def test_blacklist_seed_only_once():
with tempfile.TemporaryDirectory() as tmp:
transport = FakeFavTransport({F1: [1101], F2: []}, {1101: valid(1101)})
clock = [1_800_000_000]
db, repo, service = make_service(tmp, transport, clock)
try:
first = service.run()
assert first.blacklist_seeded == 1
clock[0] += 60
second = service.run()
assert second.blacklist_seeded == 0
assert len(repo.blacklist()) == 1
finally:
db.close()
def main():
test_initial_import_and_incremental_skip()
test_diff_add_and_remove()
test_full_refresh_marks_invalid_and_missing()
test_capacity_warning_does_not_move_folder()
test_folder_error_does_not_block_other_folders()
test_blacklist_seed_only_once()
print("OK: 收藏夹初始导入、差集同步、失效识别与容量预警测试全部通过")
if __name__ == "__main__":
main()