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:
commit
e2cdf997af
39 files changed
+8575
No files matched your search
@@ -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()
|
||||
Reference in new issue
Block a user